diff --git a/.github/workflows/ci-full.yml b/.github/workflows/ci-full.yml index 53352265034..1a25a6a1ad1 100644 --- a/.github/workflows/ci-full.yml +++ b/.github/workflows/ci-full.yml @@ -124,6 +124,9 @@ jobs: touch .env touch app/.env + - name: Exercise attested native security adapter and latency budget + shell: bash + run: bash scripts/ci/security-native-fixture.sh - name: Install checksum-pinned native test modules run: | # tinybus validates every directory ancestor. GitHub mounts the diff --git a/.github/workflows/pr-quality.yml b/.github/workflows/pr-quality.yml index eb8d3af8ab2..93ed42c3735 100644 --- a/.github/workflows/pr-quality.yml +++ b/.github/workflows/pr-quality.yml @@ -60,6 +60,9 @@ jobs: fetch-depth: 1 - name: Lychee link check uses: lycheeverse/lychee-action@v2 + env: + # Use authenticated API checks instead of anonymous GitHub page requests. + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: # Product Hunt denies GitHub-hosted runners (403) for the translated # README badge destination, so it cannot be a stable external-link @@ -72,6 +75,7 @@ jobs: # its provider-side redirect is restored. args: >- --no-progress + --max-concurrency 8 --include-fragments --exclude '^http://localhost' --exclude '^https?://127\.0\.0\.1' diff --git a/.github/workflows/security-native.yml b/.github/workflows/security-native.yml new file mode 100644 index 00000000000..30e6f01b41e --- /dev/null +++ b/.github/workflows/security-native.yml @@ -0,0 +1,54 @@ +--- +# Candidate-source integration checks. Released-artifact admission remains a +# separate requirement before the dependent migration is ready to ship. +name: Security Native + +on: + pull_request: + branches: [main, release] + paths: + - .github/workflows/security-native.yml + - .gitmodules + - vendor/tinysecurity + - crates/openhuman-core/src/modules/security*.rs + - crates/openhuman-core/src/modules/registry/records_security.rs + - crates/openhuman-core/src/security/policy/** + - scripts/ci/security-native-fixture.sh + - Cargo.lock + workflow_dispatch: {} + +permissions: + actions: read + contents: read + +concurrency: + group: security-native-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + native: + name: Native policy (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + CARGO_BUILD_JOBS: "2" + GH_TOKEN: ${{ github.token }} + steps: + - uses: actions/checkout@v7 + with: + persist-credentials: false + submodules: recursive + - name: Install Linux inference prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + key: security-native-${{ runner.os }} + - name: Exercise attested adapter and latency budget + shell: bash + run: bash scripts/ci/security-native-fixture.sh diff --git a/.github/workflows/test-reusable.yml b/.github/workflows/test-reusable.yml index ab899bfeebb..5813b7ac31d 100644 --- a/.github/workflows/test-reusable.yml +++ b/.github/workflows/test-reusable.yml @@ -283,6 +283,44 @@ jobs: fi done < <(integration_test_targets) + security-characterization: + if: inputs.run_rust_core + name: Security characterization (${{ matrix.os }}) + strategy: + fail-fast: false + matrix: + os: [ubuntu-24.04, macos-15, windows-2025] + runs-on: ${{ matrix.os }} + timeout-minutes: 60 + env: + CARGO_BUILD_JOBS: "2" + CARGO_PROFILE_DEV_DEBUG: line-tables-only + steps: + - uses: actions/checkout@v7 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + submodules: recursive + - name: Install Linux inference test prerequisites + if: runner.os == 'Linux' + run: sudo apt-get update && sudo apt-get install -y libasound2-dev pkg-config + - uses: Swatinem/rust-cache@v2 + with: + workspaces: . -> target + key: security-characterization-${{ runner.os }} + - name: Pin current security wire and redaction behavior + shell: bash + run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman-cli --no-default-features --test security_characterization_e2e + - name: Exercise private redactors with the shared corpus + shell: bash + run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman --no-default-features --features inference,web3,modules shared_security -- --nocapture + - name: Exercise attested native security adapter and latency budget + shell: bash + run: bash scripts/ci/security-native-fixture.sh + - name: Record existing in-process latency baseline + shell: bash + run: bash scripts/ci-cancel-aware.sh cargo test -p openhuman-cli --no-default-features --test security_characterization_e2e in_process_policy_latency_baseline -- --nocapture + rust-core-tests-windows: if: inputs.run_rust_core name: Rust Core Tests (Windows — secrets ACL) diff --git a/.gitmodules b/.gitmodules index b7a1f19c608..73f7995ddb5 100644 --- a/.gitmodules +++ b/.gitmodules @@ -63,3 +63,6 @@ [submodule "vendor/tinymemes"] path = vendor/tinymemes url = https://github.com/tinyhumansai/tinymemes.git +[submodule "vendor/tinysecurity"] + path = vendor/tinysecurity + url = https://github.com/tinyhumansai/tinysecurity diff --git a/AGENTS.md b/AGENTS.md index 601877e41b4..35108faaeec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -545,6 +545,7 @@ Direct rendered submodules under `vendor/`: | --- | --- | | `tinyagents` | Provider-neutral agent harness and durable typed state graph: model/tool loop, tool-call dialects and parsing, middleware, retries, caching, sessions/transcripts, and graph execution. | | `tinybox` | Isolated execution environments for code the host does not trust; box lifecycle and isolation backends. | +| `tinysecurity` | Native security policy engines and their transport-free bus contract. OpenHuman owns trusted configuration translation, product RPCs, and host execution adapters. Hosts link `tinysecurity-bus` only. | | `tinybus` | TinyBus runtime and module contracts: discovery/loading, ABI and manifest admission, transport, proxies, lifecycle, and module bus behavior. | | `tinychannels` | Portable channel/message contracts, configuration/schema, routing metadata, and channel backend abstractions. OpenHuman owns its concrete product/backend adapters. | | `tinyconnectors` | OAuth connector module behavior: account linking, available actions, action execution, and connector webhooks. | diff --git a/Cargo.lock b/Cargo.lock index 2bd0d26b493..93a8069212b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4727,6 +4727,7 @@ dependencies = [ "tinyruntime-bus", "tinyruntime-pyserver", "tinysearch-bus", + "tinysecurity-bus", "tinyskills", "tinystoragedrivers", "tinytools", @@ -4832,6 +4833,7 @@ dependencies = [ "jsonschema", "log", "openhuman", + "rand 0.10.2", "reqwest 0.12.28", "sentry", "serde", @@ -7410,7 +7412,7 @@ dependencies = [ [[package]] name = "tinybox-core" -version = "0.1.16" +version = "0.1.17" dependencies = [ "async-trait", "serde", @@ -7419,7 +7421,7 @@ dependencies = [ [[package]] name = "tinybox-docker" -version = "0.1.16" +version = "0.1.17" dependencies = [ "async-trait", "tinybox-core", @@ -7428,7 +7430,7 @@ dependencies = [ [[package]] name = "tinybox-jail" -version = "0.1.16" +version = "0.1.17" dependencies = [ "landlock", "log", @@ -8072,6 +8074,14 @@ dependencies = [ "serde_json", ] +[[package]] +name = "tinysecurity-bus" +version = "0.2.2" +dependencies = [ + "serde", + "thiserror 2.0.20", +] + [[package]] name = "tinyskills" version = "0.2.10" diff --git a/README.md b/README.md index b7901ee6f33..b576a8d44f1 100644 --- a/README.md +++ b/README.md @@ -117,7 +117,7 @@ Most agent harnesses run one heavy process per agent and resend a big prompt on

Built for developers

-

Rust quickstart · Embedding guide · Examples

+

Rust quickstart · Embedding guide · Examples

Use it as a Rust library: call an agent like any other function, or run a whole fleet from one small server.

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -Next: the [Rust quickstart](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), the [embedding guide](https://tinyhumans.gitbook.io/openhuman/developing/embedding) and the [developer docs](https://tinyhumans.gitbook.io/openhuman/developing). +Next: the [Rust quickstart](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), the [embedding guide](https://tinyhumans.gitbook.io/openhuman/developing/embed) and the [developer docs](https://tinyhumans.gitbook.io/openhuman/developing). --- diff --git a/crates/openhuman-app/Cargo.toml b/crates/openhuman-app/Cargo.toml index b43104350c3..57cb0587d0c 100644 --- a/crates/openhuman-app/Cargo.toml +++ b/crates/openhuman-app/Cargo.toml @@ -81,6 +81,7 @@ openhuman-rpc = { path = "../openhuman-rpc", default-features = false, features "web3", "documents", "modules", + "security-module", "flows", "skills", "mcp", diff --git a/crates/openhuman-cli/Cargo.toml b/crates/openhuman-cli/Cargo.toml index d2736b4d9c2..0f334f04296 100644 --- a/crates/openhuman-cli/Cargo.toml +++ b/crates/openhuman-cli/Cargo.toml @@ -161,6 +161,10 @@ path = "../../tests/memory_cortexdb_live.rs" # Skips unless OPENHUMAN_LIVE_CORTEXDB_URL is set; run it with # scripts/test-memory-cortexdb-live.sh, which boots the server in Docker. +[[test]] +name = "security_characterization_e2e" +path = "../../tests/security_characterization_e2e.rs" + [[test]] name = "json_rpc_e2e" path = "../../tests/json_rpc_e2e.rs" @@ -374,6 +378,7 @@ documents = ["openhuman-rpc/documents"] hosting = ["openhuman-rpc/hosting"] tinymemes = ["openhuman-rpc/tinymemes"] modules = ["openhuman-rpc/modules"] +security-module = ["openhuman-rpc/security-module"] voice = ["openhuman-rpc/voice"] web3 = ["openhuman-rpc/web3"] # Storage drivers for `[storage] url` (see the core `storage` domain). diff --git a/crates/openhuman-core/Cargo.toml b/crates/openhuman-core/Cargo.toml index cbf7cbcca3e..3f2c4ea6ed3 100644 --- a/crates/openhuman-core/Cargo.toml +++ b/crates/openhuman-core/Cargo.toml @@ -320,6 +320,7 @@ tinychannels = { version = "0.1", features = ["relay-websocket"], optional = tru # never loads a module. The kernel-floor ratchet catches exactly that, and did. tinybus = { path = "../../vendor/tinybus/crates/tinybus", default-features = false, features = ["macros", "uds"] } tinysearch-bus = { path = "../../vendor/tinysearch/crates/tinysearch-bus" } +tinysecurity-bus = { path = "../../vendor/tinysecurity/crates/tinysecurity-bus", optional = true } tinycomputer-bus = { path = "../../vendor/tinycomputer/crates/tinycomputer-bus", optional = true } # Desktop accessibility middleware, linked as a plain library (not through the # tinycomputer module): the voice pipeline needs focus lookup, paste validation, @@ -832,6 +833,7 @@ tinymemes = ["dep:tinymemes"] # tinybus never unloads one. See `crates/openhuman-core/src/modules/` before adding an entry # to the registry. modules = ["tinybus/modules", "dep:tinycomputer-bus"] +security-module = ["modules", "dep:tinysecurity-bus"] # Voice + audio_toolkit domains: STT/TTS providers, the standalone dictation # server, always-on listening, and podcast audio generation/email delivery. # Default-ON — the desktop app always ships with voice. Slim / headless builds diff --git a/crates/openhuman-core/src/agent/orchestration/background_completions_isolation_tests.rs b/crates/openhuman-core/src/agent/orchestration/background_completions_isolation_tests.rs new file mode 100644 index 00000000000..6d7c0269d83 --- /dev/null +++ b/crates/openhuman-core/src/agent/orchestration/background_completions_isolation_tests.rs @@ -0,0 +1,38 @@ +use super::*; + +pub(super) fn assert_session_cache_eviction() { + // Other modules register completions without TEST_ENV_LOCK. Their inserts + // legitimately evict older sessions from this process-wide bounded cache, + // so exercise the exact eviction boundary in a process with no other tests. + const CHILD: &str = "OPENHUMAN_SESSION_CACHE_EVICTION_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "agent::orchestration::background_completions::tests::the_session_cache_evicts_its_oldest_mapping_only", + "--nocapture", + ]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let _guard = test_guard(); + for i in 0..(SESSION_THREADS_CAP + 5) { + note_session_thread(&format!("evict-sess-{i}"), &format!("evict-thread-{i}")); + } + assert_eq!(thread_for_session("evict-sess-0"), None, "oldest evicted"); + for survivor in [5, SESSION_THREADS_CAP / 2, SESSION_THREADS_CAP + 4] { + assert_eq!( + thread_for_session(&format!("evict-sess-{survivor}")).as_deref(), + Some(format!("evict-thread-{survivor}").as_str()), + "a surviving session still resolves to its own thread" + ); + } +} diff --git a/crates/openhuman-core/src/agent/orchestration/background_completions_tests.rs b/crates/openhuman-core/src/agent/orchestration/background_completions_tests.rs index d68c5c4a75a..11b77b11dab 100644 --- a/crates/openhuman-core/src/agent/orchestration/background_completions_tests.rs +++ b/crates/openhuman-core/src/agent/orchestration/background_completions_tests.rs @@ -534,20 +534,12 @@ fn clear_all_also_withdraws_a_completion_a_delivery_has_leased() { #[test] fn the_session_cache_evicts_its_oldest_mapping_only() { - let _guard = test_guard(); - for i in 0..(SESSION_THREADS_CAP + 5) { - note_session_thread(&format!("evict-sess-{i}"), &format!("evict-thread-{i}")); - } - assert_eq!(thread_for_session("evict-sess-0"), None, "oldest evicted"); - for survivor in [5, SESSION_THREADS_CAP / 2, SESSION_THREADS_CAP + 4] { - assert_eq!( - thread_for_session(&format!("evict-sess-{survivor}")).as_deref(), - Some(format!("evict-thread-{survivor}").as_str()), - "a surviving session still resolves to its own thread" - ); - } + isolation::assert_session_cache_eviction(); } +#[path = "background_completions_isolation_tests.rs"] +mod isolation; + #[test] fn a_delete_marker_outlives_compaction() { let _guard = test_guard(); diff --git a/crates/openhuman-core/src/agent/tinyagents/middleware/credential_scrub_tests.rs b/crates/openhuman-core/src/agent/tinyagents/middleware/credential_scrub_tests.rs index aa9dfcb9717..b6474e8c865 100644 --- a/crates/openhuman-core/src/agent/tinyagents/middleware/credential_scrub_tests.rs +++ b/crates/openhuman-core/src/agent/tinyagents/middleware/credential_scrub_tests.rs @@ -62,3 +62,29 @@ fn source_code_about_tokens_passes_through_the_host_scrubber() { assert_eq!(count, 1); assert!(!scrubbed.contains("hunter2secret")); } + +#[test] +fn shared_security_corpus_pins_credential_middleware_catches_and_gaps() { + let corpus: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/security-redaction-corpus.json" + ))) + .unwrap(); + for case in corpus["text"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + let output = scrub_with_notice_for_tool("read_file", input) + .map(|(text, _)| text) + .unwrap_or_else(|| input.to_owned()); + let removed = case["removed_by"] + .as_array() + .unwrap() + .iter() + .any(|redactor| redactor == "credential_middleware"); + assert_eq!( + !output.contains(case["needle"].as_str().unwrap()), + removed, + "{}: {output}", + case["case"] + ); + } +} diff --git a/crates/openhuman-core/src/config/schema/load_migration_tests.rs b/crates/openhuman-core/src/config/schema/load_migration_tests.rs index 02273ce2f08..90d3ca07961 100644 --- a/crates/openhuman-core/src/config/schema/load_migration_tests.rs +++ b/crates/openhuman-core/src/config/schema/load_migration_tests.rs @@ -664,3 +664,18 @@ fn resolve_action_dir_rejects_empty_override() { "empty override must be ignored, falling back to default" ); } + +#[test] +fn shared_security_url_corpus_pins_migration_redactor() { + let corpus: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/security-redaction-corpus.json" + ))) + .unwrap(); + for case in corpus["urls"].as_array().unwrap() { + assert_eq!( + redact_url_for_log(case["input"].as_str().unwrap()), + case["migration"] + ); + } +} diff --git a/crates/openhuman-core/src/flows/tinyflows/caps/tools/composio_tests.rs b/crates/openhuman-core/src/flows/tinyflows/caps/tools/composio_tests.rs index 96c079e84cc..3e3305e0c1d 100644 --- a/crates/openhuman-core/src/flows/tinyflows/caps/tools/composio_tests.rs +++ b/crates/openhuman-core/src/flows/tinyflows/caps/tools/composio_tests.rs @@ -58,6 +58,26 @@ async fn backend_dispatch_forwards_the_workflow_connection_id() { ) .expect("store test session token"); + assert_eq!( + crate::security::credentials::session_support::resolve_backend_credential(&config) + .expect("resolve fixture credential"), + crate::security::credentials::session_support::BackendCredential::Session( + "test-token".to_owned() + ) + ); + let route = crate::modules::connectors::module_config(&config) + .expect("fixture must configure the connector backend route"); + assert_eq!(route["route"], "proxy"); + assert_eq!(route["base_url"], format!("http://{addr}")); + crate::modules::ops::ensure_loaded(&config, "tinyconnectors") + .await + .expect("load fixture connector module"); + assert_eq!( + crate::modules::connectors::module_config(&config) + .expect("module loading must retain the fixture backend route")["route"], + "proxy" + ); + let response = execute_for_connection( &config, "GITHUB_LIST_REPOSITORY_ISSUES", diff --git a/crates/openhuman-core/src/inference/provider/factory_tests.rs b/crates/openhuman-core/src/inference/provider/factory_tests.rs index c7357a49192..4fde9883391 100644 --- a/crates/openhuman-core/src/inference/provider/factory_tests.rs +++ b/crates/openhuman-core/src/inference/provider/factory_tests.rs @@ -191,3 +191,20 @@ mod crate_native_tests; mod egress_fallback_tests; #[path = "factory_route_resolution_tests.rs"] mod route_resolution_tests; + +#[test] +fn shared_security_url_corpus_pins_inference_log_redactors() { + let corpus: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/security-redaction-corpus.json" + ))) + .unwrap(); + for case in corpus["urls"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + assert_eq!(redact_endpoint(input), case["endpoint"]); + assert_eq!( + primary_cloud::redact_inference_url(Some(input)), + case["inference"] + ); + } +} diff --git a/crates/openhuman-core/src/integrations/composio/tools_tests.rs b/crates/openhuman-core/src/integrations/composio/tools_tests.rs index 3c549291593..f3c9b93e092 100644 --- a/crates/openhuman-core/src/integrations/composio/tools_tests.rs +++ b/crates/openhuman-core/src/integrations/composio/tools_tests.rs @@ -91,3 +91,23 @@ mod host_credential_tests; mod metadata_and_sandbox_tests; #[path = "tools_redact_tests.rs"] mod redact_tests; + +#[test] +fn shared_security_corpus_pins_configured_secret_redaction() { + let corpus: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/security-redaction-corpus.json" + ))) + .unwrap(); + for case in corpus["text"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + let secret = case["composio_secret"].as_str().unwrap().to_owned(); + let output = redact::redact_text(input, &[secret]); + let expected = if case["case"] == "anthropic_key" { + "[REDACTED]" + } else { + input + }; + assert_eq!(output, expected, "{}", case["case"]); + } +} diff --git a/crates/openhuman-core/src/modules/mod.rs b/crates/openhuman-core/src/modules/mod.rs index 62b4d6369fe..e41e11f4ebf 100644 --- a/crates/openhuman-core/src/modules/mod.rs +++ b/crates/openhuman-core/src/modules/mod.rs @@ -57,6 +57,8 @@ pub mod registry; pub mod runtime; pub mod schemas; pub mod search; +#[cfg(feature = "security-module")] +pub mod security; mod tokenjuice_host; pub mod types; #[cfg(feature = "voice")] diff --git a/crates/openhuman-core/src/modules/ops.rs b/crates/openhuman-core/src/modules/ops.rs index 04b68c9c080..de4218d5cc4 100644 --- a/crates/openhuman-core/src/modules/ops.rs +++ b/crates/openhuman-core/src/modules/ops.rs @@ -410,6 +410,11 @@ pub(super) fn load_local( /// Credentials are intentionally absent. TinyMemory calls back into the host /// for embedding and chat compute; the other modules need no host config. fn module_config(config: &Config, id: &str) -> serde_json::Value { + #[cfg(feature = "security-module")] + if id == super::security::MODULE_ID { + return serde_json::to_value(super::security::module_config(config)) + .expect("TinySecurity config serializes"); + } if id == super::search::MODULE_ID { return serde_json::to_value(super::search::module_config(config)) .expect("TinySearch config serializes"); diff --git a/crates/openhuman-core/src/modules/registry.rs b/crates/openhuman-core/src/modules/registry.rs index 9725429757d..edd2f8e1f17 100644 --- a/crates/openhuman-core/src/modules/registry.rs +++ b/crates/openhuman-core/src/modules/registry.rs @@ -41,6 +41,8 @@ mod records_mcp_connectors; mod records_memory_juice; mod records_runtime; mod records_search; +#[cfg(feature = "security-module")] +mod records_security; mod records_voice; use crate::modules::types::ModuleRecord; @@ -51,6 +53,8 @@ use records_mcp_connectors::{TINYCONNECTORS, TINYMCP}; use records_memory_juice::TINYJUICE; use records_runtime::{TINYRUNTIME, TINYRUNTIME_NODEJS, TINYRUNTIME_PYTHON}; use records_search::TINYSEARCH; +#[cfg(feature = "security-module")] +use records_security::TINYSECURITY; use records_voice::TINYVOICE; /// Every module this build can load. @@ -69,6 +73,8 @@ pub const ALL: &[ModuleRecord] = &[ TINYBOX, TINYCHANNELS, TINYHOSTS, + #[cfg(feature = "security-module")] + TINYSECURITY, ]; /// The record for `id`, if this build knows it. diff --git a/crates/openhuman-core/src/modules/registry/records_extra.rs b/crates/openhuman-core/src/modules/registry/records_extra.rs index ef342422877..b0f22ac5561 100644 --- a/crates/openhuman-core/src/modules/registry/records_extra.rs +++ b/crates/openhuman-core/src/modules/registry/records_extra.rs @@ -8,63 +8,63 @@ pub(crate) const TINYBOX: ModuleRecord = ModuleRecord { description: "Sandbox capability discovery through TinyBox", bus_name: "ai.tinyhumans.tinybox.Box", object_path: "/ai/tinyhumans/tinybox/Box", - version: "0.1.16", - release_url: "https://github.com/tinyhumansai/tinybox/releases/tag/v0.1.16", + version: "0.1.17", + release_url: "https://github.com/tinyhumansai/tinybox/releases/tag/v0.1.17", assets: &[ PlatformAsset { host_key: "ubuntu-24.04-x86_64", - archive: "tinybox-0.1.16-ubuntu-24.04-x86_64.tar.gz", - sha256: "3163f7cf621beaf71d99b978555085e6bb933a0810153970dd16b2c531e1a030", + archive: "tinybox-0.1.17-ubuntu-24.04-x86_64.tar.gz", + sha256: "be13b54f9d6a039c9de484f63363f647d8c3d2d8f0cc14b768da83372941ce5a", }, PlatformAsset { host_key: "ubuntu-24.04-arm64", - archive: "tinybox-0.1.16-ubuntu-24.04-arm64.tar.gz", - sha256: "fad323bf74ce075f758a31c7cb93f393d84ac4c9a6a22f31fb7fc7e2ec4743c4", + archive: "tinybox-0.1.17-ubuntu-24.04-arm64.tar.gz", + sha256: "0489d5e8591c1a04f2733f0178495b2c232eecec1e3aee3ff67fe2248bc59b68", }, PlatformAsset { host_key: "ubuntu-22.04-x86_64", - archive: "tinybox-0.1.16-ubuntu-22.04-x86_64.tar.gz", - sha256: "9abddc0e8ad14ac9f34e479714baa7f1720ed029d199272214450f356b7d51da", + archive: "tinybox-0.1.17-ubuntu-22.04-x86_64.tar.gz", + sha256: "a14eba80278bc311e67cd19db174a66f39a519663f5f07f9d0ebcf14b34d89e4", }, PlatformAsset { host_key: "ubuntu-22.04-arm64", - archive: "tinybox-0.1.16-ubuntu-22.04-arm64.tar.gz", - sha256: "fb164eeec76789035de8c621176630b6ea6aea0a5021778fbd97fafe163b6dac", + archive: "tinybox-0.1.17-ubuntu-22.04-arm64.tar.gz", + sha256: "3db57724a935e3e859afc107850a31b51df6f5794467754163ab8a4c1bf869ec", }, PlatformAsset { host_key: "macos-26-arm64", - archive: "tinybox-0.1.16-macos-26-arm64.tar.gz", - sha256: "34cb87457a3b21ec5ee8b8b6817f6a78a854b12e4d40fa43221aeed508d7c40a", + archive: "tinybox-0.1.17-macos-26-arm64.tar.gz", + sha256: "6e285f3505664ef6aa59d683542320384c6036b3b858d1d67177242587ed305b", }, PlatformAsset { host_key: "macos-26-x86_64", - archive: "tinybox-0.1.16-macos-26-x86_64.tar.gz", - sha256: "5f7a4886fb191a58dc33bb5f43a7034c0f6b31c1e85161e13228aa0deb6ae5b9", + archive: "tinybox-0.1.17-macos-26-x86_64.tar.gz", + sha256: "bae82655ed7301ba1b9a2caa427ccd53924195821326305edfa19b9ebcb3f0c4", }, PlatformAsset { host_key: "macos-15-arm64", - archive: "tinybox-0.1.16-macos-15-arm64.tar.gz", - sha256: "ae427b7962b0607051595c9c12f9cb630c87672bc3c92c7becdac87283aaefd4", + archive: "tinybox-0.1.17-macos-15-arm64.tar.gz", + sha256: "b531f41a8266c59a2240f139917838e4185dbe80642626e56da2ebb4814dd5d9", }, PlatformAsset { host_key: "macos-15-x86_64", - archive: "tinybox-0.1.16-macos-15-x86_64.tar.gz", - sha256: "417d4c182c3882cd90b5ff323dc81c62c2d98b3e0bb8ac0a237cac51c34828fe", + archive: "tinybox-0.1.17-macos-15-x86_64.tar.gz", + sha256: "7dbbd9ea55bff99ac554032ba26963672db8cc77644059de89b3d2739c05fbd2", }, PlatformAsset { host_key: "windows-2025-x86_64", - archive: "tinybox-0.1.16-windows-2025-x86_64.zip", - sha256: "ada7ddb1edf16887ef20d84241f0453b2ce8ee29aa670c1505758c1f7913b4fc", + archive: "tinybox-0.1.17-windows-2025-x86_64.zip", + sha256: "3c4546481875de85a8fed6b9e0cab78c4af6b83a2958de513284b5d5b7f930d0", }, PlatformAsset { host_key: "windows-2022-x86_64", - archive: "tinybox-0.1.16-windows-2022-x86_64.zip", - sha256: "044666f53b10c8db6626262de45806d1a2a34147ce2de196e8549987f32c1cd8", + archive: "tinybox-0.1.17-windows-2022-x86_64.zip", + sha256: "86cc55e9e9abe895eab0c4cd547036330f5cef7a0680ab88c1087d531a929900", }, PlatformAsset { host_key: "windows-11-arm64", - archive: "tinybox-0.1.16-windows-11-arm64.zip", - sha256: "bc3da524fc3e702e77cc1e85c064dccaece633f896503737c71ce7127ba26440", + archive: "tinybox-0.1.17-windows-11-arm64.zip", + sha256: "0fb3f7c2df91ea4027771f877fcec8456ca12675295c2bce3694e89e50c25266", }, ], load: LoadPolicy::Lazy, diff --git a/crates/openhuman-core/src/modules/registry/records_security.rs b/crates/openhuman-core/src/modules/registry/records_security.rs new file mode 100644 index 00000000000..02987608ff7 --- /dev/null +++ b/crates/openhuman-core/src/modules/registry/records_security.rs @@ -0,0 +1,69 @@ +//! Published TinySecurity admission pins copied verbatim from v0.2.2/checksum.toml. +use crate::modules::types::{LoadPolicy, ModuleRecord, PlatformAsset}; + +pub(crate) const TINYSECURITY: ModuleRecord = ModuleRecord { + id: "tinysecurity", + description: "Native security policy and immutable filesystem authorization scopes", + bus_name: tinysecurity_bus::names::INTERFACE, + object_path: tinysecurity_bus::names::OBJECT_PATH, + version: "0.2.2", + release_url: "https://github.com/tinyhumansai/tinysecurity/releases/tag/v0.2.2", + assets: &[ + PlatformAsset { + host_key: "ubuntu-24.04-x86_64", + archive: "tinysecurity-module-0.2.2-ubuntu-24.04-x86_64.tar.gz", + sha256: "15e15e19676089a344f75954d4a48df3dd2e0b26b84b93f1a3df9546687a0ee7", + }, + PlatformAsset { + host_key: "ubuntu-24.04-arm64", + archive: "tinysecurity-module-0.2.2-ubuntu-24.04-arm64.tar.gz", + sha256: "efda938512324708e8dd118483f082a3cdccd479465c21381178b16301fe498c", + }, + PlatformAsset { + host_key: "ubuntu-22.04-x86_64", + archive: "tinysecurity-module-0.2.2-ubuntu-22.04-x86_64.tar.gz", + sha256: "c8705920c2a913dbe6ded085d4a294f9c38a21b993a7407037ac6a23825af405", + }, + PlatformAsset { + host_key: "ubuntu-22.04-arm64", + archive: "tinysecurity-module-0.2.2-ubuntu-22.04-arm64.tar.gz", + sha256: "25745c487ab411eca5a495fa06fdf0db4ba4dbe4a8e75b4fadcd7211a27f67d3", + }, + PlatformAsset { + host_key: "macos-26-arm64", + archive: "tinysecurity-module-0.2.2-macos-26-arm64.tar.gz", + sha256: "415e4e5e65dacbfe7bc0d5fff9e31d712e60cefa1bceae089c31a148a0a039f4", + }, + PlatformAsset { + host_key: "macos-26-x86_64", + archive: "tinysecurity-module-0.2.2-macos-26-x86_64.tar.gz", + sha256: "17cc99c81a0f451e2fb2f8fcdd7d140e25f38e92a4c1abf499da5cd8368e952a", + }, + PlatformAsset { + host_key: "macos-15-arm64", + archive: "tinysecurity-module-0.2.2-macos-15-arm64.tar.gz", + sha256: "b9982f24ce251435030f6ef22dd36af45940eba23160d26c53f136eecac1e3ea", + }, + PlatformAsset { + host_key: "macos-15-x86_64", + archive: "tinysecurity-module-0.2.2-macos-15-x86_64.tar.gz", + sha256: "c5eb37f160967bdf28c873d9a5a74cc217f3b6cf5539d68c6c58f2e1dc0f8542", + }, + PlatformAsset { + host_key: "windows-2025-x86_64", + archive: "tinysecurity-module-0.2.2-windows-2025-x86_64.zip", + sha256: "b576f789ad34b969166a77b952b15f5a2229c0c40ba35c60f3faa0630a85bd7e", + }, + PlatformAsset { + host_key: "windows-2022-x86_64", + archive: "tinysecurity-module-0.2.2-windows-2022-x86_64.zip", + sha256: "0ae898d66954edb24103843e88ce3487e6970b5a4560791f3880b69db90fc54d", + }, + PlatformAsset { + host_key: "windows-11-arm64", + archive: "tinysecurity-module-0.2.2-windows-11-arm64.zip", + sha256: "6423f1eb1c76cd32c1c65fc784ff539900554c8a9857a42a9f147a30d0691fc0", + }, + ], + load: LoadPolicy::Eager, +}; diff --git a/crates/openhuman-core/src/modules/registry_tests.rs b/crates/openhuman-core/src/modules/registry_tests.rs index 244e02d3553..189a4023046 100644 --- a/crates/openhuman-core/src/modules/registry_tests.rs +++ b/crates/openhuman-core/src/modules/registry_tests.rs @@ -1,6 +1,15 @@ use super::{find, ALL}; use tinybus::module::platform::candidates_for; +#[cfg(feature = "security-module")] +#[test] +fn tinysecurity_is_eager_and_matches_the_typed_policy_contract() { + let record = find("tinysecurity").expect("compiled native security module"); + assert_eq!(record.bus_name, tinysecurity_bus::names::INTERFACE); + assert_eq!(record.object_path, tinysecurity_bus::names::OBJECT_PATH); + assert_eq!(record.load, crate::modules::LoadPolicy::Eager); +} + #[test] fn tinycomputer_registry_matches_bus_contract_and_published_release() { let desktop = find("tinycomputer").expect("compiled computer module"); diff --git a/crates/openhuman-core/src/modules/security.rs b/crates/openhuman-core/src/modules/security.rs new file mode 100644 index 00000000000..b84b4172147 --- /dev/null +++ b/crates/openhuman-core/src/modules/security.rs @@ -0,0 +1,443 @@ +//! Trusted host adapter for the separately loaded TinySecurity policy module. +//! +//! Only the bus contract is linked. A missing, untrusted, timed-out or malformed +//! module response is an error, never permission to execute. The bootstrap +//! engine has a narrow diagnostic surface; acting path policy uses immutable +//! module-owned scopes rather than the process-global bootstrap policy. + +use std::{sync::OnceLock, time::Duration}; + +use tinysecurity_bus::{ + names, CallerContext, Check, CheckRequest, CheckResponse, Decision, EvaluateRequest, + ModuleConfig, PolicyInfo, ToolCall, +}; + +use crate::config::Config; + +pub const MODULE_ID: &str = "tinysecurity"; +const CALL_TIMEOUT: Duration = Duration::from_secs(2); +const LOAD_TIMEOUT: Duration = Duration::from_secs(8); + +/// Bootstrap configuration delivered privately at SDK initialization. Acting +/// policy is bound separately to immutable scopes, never the process-global +/// bootstrap configuration. No OpenHuman enabled-policy settings are dropped +/// into this configuration as if the bootstrap engine could enforce them. +pub fn module_config(_config: &Config) -> ModuleConfig { + ModuleConfig::default() +} + +/// Caller identity supplied exclusively by trusted host dispatch. This wrapper +/// intentionally has no Deserialize implementation or model-facing controller. +pub(crate) struct VerifiedCaller(CallerContext); + +impl VerifiedCaller { + /// Wrap already authenticated identity and authority, never model arguments. + pub(crate) fn from_host(context: CallerContext) -> Result { + if context.agent.trim().is_empty() || context.call_id.trim().is_empty() { + return Err("TinySecurity caller identity is missing".into()); + } + Ok(Self(context)) + } +} + +#[derive(Default)] +struct ClientState { + generation: Option, + info: Option, + proxy: Option, + scopes: Vec<(tinysecurity_bus::PathPolicy, tinysecurity_bus::PathPolicyId)>, + faulted: bool, +} + +fn state() -> &'static tokio::sync::Mutex { + static STATE: OnceLock> = OnceLock::new(); + STATE.get_or_init(|| tokio::sync::Mutex::new(ClientState::default())) +} + +fn require_enabled(config: &Config) -> Result<(), String> { + if !config.modules.enabled { + return Err("TinySecurity modules are disabled".into()); + } + Ok(()) +} + +fn pinned(record: &super::ModuleRecord, digest: &str) -> bool { + record + .assets + .iter() + .any(|asset| asset.sha256.eq_ignore_ascii_case(digest)) +} + +async fn proxy(config: &Config, state: &mut ClientState) -> Result { + require_enabled(config)?; + if state.faulted { + return Err("TinySecurity client faulted; restart required".into()); + } + if let Some(proxy) = &state.proxy { + return Ok(proxy.clone()); + } + let configuration = module_config(config); + super::ops::ensure_loaded_within(config, MODULE_ID, Some(LOAD_TIMEOUT)) + .await + .map_err(super::ops::LoadError::into_message)?; + let runtime = super::host::runtime() + .await + .map_err(|_| "TinySecurity bus unavailable")?; + let record = super::registry::find(MODULE_ID).ok_or("TinySecurity registry entry missing")?; + let proxy = runtime + .proxy(names::INTERFACE, names::OBJECT_PATH) + .map_err(|_| "TinySecurity proxy unavailable")? + .with_timeout(CALL_TIMEOUT); + require_attestation(&proxy, record).await?; + let mut info: PolicyInfo = proxy + .call(names::methods::POLICY_INFO, ()) + .await + .map_err(|_| "TinySecurity PolicyInfo failed")?; + // Init configuration is delivered privately by the loader. An eager load + // may belong to a different agent, so compare the actual effective policy + // even on the first call. Never rely on a process-global fingerprint alone. + if info.policy != configuration.policy { + tokio::time::timeout( + CALL_TIMEOUT, + runtime.connection().reinitialize_module( + "tinysecurity-module", + serde_json::to_value(&configuration) + .map_err(|_| "TinySecurity config serialization failed")?, + ), + ) + .await + .map_err(|_| "TinySecurity configuration refresh timed out")? + .map_err(|_| "TinySecurity configuration refresh failed")?; + info = proxy + .call(names::methods::POLICY_INFO, ()) + .await + .map_err(|_| "TinySecurity PolicyInfo failed")?; + } + validate_info(&info, &configuration)?; + state.generation = Some(info.generation); + state.info = Some(info); + state.proxy = Some(proxy.clone()); + Ok(proxy) +} + +async fn require_attestation( + proxy: &tinybus::Proxy, + record: &super::ModuleRecord, +) -> Result<(), String> { + let attestation = tokio::time::timeout(CALL_TIMEOUT, proxy.attestation()) + .await + .map_err(|_| "TinySecurity attestation timed out")? + .map_err(|_| "TinySecurity attestation failed")? + .ok_or("TinySecurity recipient is not attested")?; + if attestation.name.as_str() != record.bus_name || !pinned(record, &attestation.sha256) { + return Err("TinySecurity recipient does not match a pinned release".into()); + } + Ok(()) +} + +fn validate_info(info: &PolicyInfo, config: &ModuleConfig) -> Result<(), String> { + if !tinysecurity_bus::is_compatible(info.contract_version) + || info.generation == 0 + || info.policy != config.policy + || names::METHODS.iter().any(|method| { + !info + .capabilities + .iter() + .any(|available| available == method) + }) + { + return Err("TinySecurity effective policy or contract mismatch".into()); + } + Ok(()) +} + +/// Read effective policy from the attested module, for authenticated host UI. +pub async fn policy_info(config: &Config) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + proxy(config, &mut state).await?; + state + .info + .clone() + .ok_or_else(|| "TinySecurity policy unavailable".into()) + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +/// Evaluate original arguments with complete host-derived effects. Errors deny +/// execution; no in-process fallback or automatic retry is performed. +pub(crate) async fn evaluate( + config: &Config, + caller: VerifiedCaller, + call: ToolCall, +) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(config, &mut state).await?; + let decision: Decision = proxy + .call_confidential( + names::methods::EVALUATE, + (EvaluateRequest { + caller: caller.0, + call, + },), + ) + .await + .map_err(|_| "TinySecurity Evaluate failed")?; + validate_decision(&decision, state.generation)?; + Ok(decision) + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +/// Batch concrete in-tool effects under one accepted policy generation. +pub(crate) async fn check( + config: &Config, + caller: VerifiedCaller, + checks: Vec, +) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(config, &mut state).await?; + let count = checks.len(); + let response: CheckResponse = proxy + .call_confidential( + names::methods::CHECK, + (CheckRequest { + caller: caller.0, + checks, + },), + ) + .await + .map_err(|_| "TinySecurity Check failed")?; + if response.decisions.len() != count || Some(response.generation) != state.generation { + return Err("TinySecurity batch response mismatch".into()); + } + for decision in &response.decisions { + validate_decision(decision, state.generation)?; + } + Ok(response) + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +fn validate_decision(decision: &Decision, generation: Option) -> Result<(), String> { + if Some(decision.generation) != generation || decision.generation == 0 || decision.cacheable { + return Err("TinySecurity decision generation or caching mismatch".into()); + } + Ok(()) +} + +#[cfg(test)] +#[path = "security_tests.rs"] +mod tests; + +/// Register one immutable acting policy on the attested native module. Host +/// paths travel confidentially and never become a public controller payload. +pub(crate) async fn register_path_policy( + config: &Config, + policy: tinysecurity_bus::PathPolicy, +) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(config, &mut state).await?; + register_cached_scope(&mut state, &proxy, policy).await + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +/// Resolve and authorize an existing path under a previously registered scope. +pub(crate) async fn validate_path( + config: &Config, + policy_id: tinysecurity_bus::PathPolicyId, + path: String, +) -> Result { + validate_native_path(config, policy_id, path, names::methods::VALIDATE_PATH).await +} + +/// Resolve and authorize the existing parent of a proposed file destination. +pub(crate) async fn validate_parent( + config: &Config, + policy_id: tinysecurity_bus::PathPolicyId, + path: String, +) -> Result { + validate_native_path(config, policy_id, path, names::methods::VALIDATE_PARENT).await +} + +async fn validate_native_path( + config: &Config, + policy_id: tinysecurity_bus::PathPolicyId, + path: String, + member: &str, +) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(config, &mut state).await?; + call_validate_path(&proxy, policy_id, path, member).await + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +/// Query one native path helper using host-derived query kind and scope. +pub(crate) async fn check_path( + config: &Config, + policy_id: tinysecurity_bus::PathPolicyId, + path: std::path::PathBuf, + kind: tinysecurity_bus::PathCheckKind, +) -> Result { + require_enabled(config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(config, &mut state).await?; + proxy + .call_confidential( + names::methods::CHECK_PATH, + (tinysecurity_bus::PathCheckRequest { + policy_id, + path, + kind, + },), + ) + .await + .map_err(|_| "TinySecurity CheckPath failed".into()) + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +/// Resolve a concrete acting operation under host-derived immutable policy. +/// Configuration comes from the current dispatch context; the caller cannot +/// substitute a module source, runtime, scope identity or bootstrap settings. +pub(crate) async fn validate_host_path( + policy: tinysecurity_bus::PathPolicy, + path: &str, + parent: bool, +) -> Result { + let config = + config::host_config(&policy.workspace_dir, crate::core::runtime::is_saas()).await?; + require_enabled(&config)?; + let mut state = state().lock().await; + let result = async { + let proxy = proxy(&config, &mut state).await?; + call_host_path(&mut state, &proxy, policy, path, parent).await + } + .await; + if result.is_err() { + state.faulted = true; + } + result +} + +async fn register_cached_scope( + state: &mut ClientState, + proxy: &tinybus::Proxy, + policy: tinysecurity_bus::PathPolicy, +) -> Result { + if let Some((_, id)) = state + .scopes + .iter() + .find(|(registered, _)| registered == &policy) + { + return Ok(id.clone()); + } + if state.scopes.len() >= 1024 { + return Err("TinySecurity host path scope limit reached".into()); + } + let id = call_register_path_policy(proxy, policy.clone()).await?; + state.scopes.push((policy, id.clone())); + Ok(id) +} + +/// Shared production/native-fixture path operation. Warmed clients with equal +/// immutable scopes use one bus call; first use adds one registration call. +/// No permission result or filesystem resolution is cached. +async fn call_host_path( + state: &mut ClientState, + proxy: &tinybus::Proxy, + policy: tinysecurity_bus::PathPolicy, + path: &str, + parent: bool, +) -> Result { + let id = register_cached_scope(state, proxy, policy).await?; + let member = if parent { + names::methods::VALIDATE_PARENT + } else { + names::methods::VALIDATE_PATH + }; + call_validate_path(proxy, id, path.to_owned(), member).await +} + +async fn call_register_path_policy( + proxy: &tinybus::Proxy, + policy: tinysecurity_bus::PathPolicy, +) -> Result { + let id: tinysecurity_bus::PathPolicyId = proxy + .call_confidential(names::methods::REGISTER_PATH_POLICY, (policy,)) + .await + .map_err(|_| "TinySecurity RegisterPathPolicy failed")?; + if id.0.is_empty() { + return Err("TinySecurity returned an empty path scope".into()); + } + Ok(id) +} + +async fn call_validate_path( + proxy: &tinybus::Proxy, + policy_id: tinysecurity_bus::PathPolicyId, + path: String, + member: &str, +) -> Result { + let result: tinysecurity_bus::PathValidationResult = proxy + .call_confidential( + member, + (tinysecurity_bus::PathValidationRequest { policy_id, path },), + ) + .await + .map_err(|_| "TinySecurity path validation failed")?; + validate_path_response(&result)?; + Ok(result) +} + +fn validate_path_response(result: &tinysecurity_bus::PathValidationResult) -> Result<(), String> { + if let tinysecurity_bus::PathValidationResult::Allowed { resolved } = result { + if !resolved.is_absolute() { + return Err("TinySecurity returned a relative resolved path".into()); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "security_native_tests.rs"] +mod native_tests; + +#[path = "security_config.rs"] +mod config; diff --git a/crates/openhuman-core/src/modules/security_config.rs b/crates/openhuman-core/src/modules/security_config.rs new file mode 100644 index 00000000000..9ff344ca4a5 --- /dev/null +++ b/crates/openhuman-core/src/modules/security_config.rs @@ -0,0 +1,72 @@ +//! Module source settings bound to the authenticated dispatch, without auth, +//! environment or operator fallback in SaaS mode. +use super::*; + +pub(super) async fn host_config(workspace: &std::path::Path, saas: bool) -> Result { + if !saas { + return match crate::core::runtime::CoreContext::current_embedder_config() { + Some(config) => Ok(config), + None => crate::config::ops::load_config_for_workspace_with_timeout(workspace).await, + }; + } + let context = crate::core::runtime::CoreContext::scoped() + .ok_or("TinySecurity requires an authenticated SaaS dispatch scope")?; + if context + .workspace_dir() + .map_err(|_| "TinySecurity SaaS workspace unavailable")? + != workspace + { + return Err("TinySecurity SaaS workspace does not match authenticated scope".into()); + } + if let Some(config) = context.embedder_config() { + return Ok(config.clone()); + } + // No native call has started: missing authority/configuration refuses this + // request but must not latch the process-wide module client as faulted. + tokio::time::timeout(LOAD_TIMEOUT, scoped_module_file(workspace)) + .await + .map_err(|_| "TinySecurity SaaS configuration loading timed out")? +} + +#[derive(Default, serde::Deserialize)] +#[serde(default)] +struct ModuleDocument { + modules: crate::config::schema::ModulesConfig, +} + +async fn scoped_module_file(workspace: &std::path::Path) -> Result { + // Only the known account/workspace layout permits looking beside the + // workspace. Arbitrary workspaces never inherit a common parent's config. + let account_config = (workspace.file_name() == Some(std::ffi::OsStr::new("workspace"))) + .then(|| workspace.parent().map(|parent| parent.join("config.toml"))) + .flatten(); + for candidate in [Some(workspace.join("config.toml")), account_config] + .into_iter() + .flatten() + { + let text = match tokio::fs::read_to_string(&candidate).await { + Ok(text) => text, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(_) => return Err("TinySecurity SaaS configuration unreadable".into()), + }; + // Generic Config reloads can synthesize defaults and apply process env + // when a source disappears. Parse only module settings from these exact + // bytes, so a missing/invalid tenant source cannot import operator state. + let document = tokio::task::spawn_blocking(move || toml::from_str::(&text)) + .await + .map_err(|_| "TinySecurity SaaS configuration parsing failed")? + .map_err(|_| "TinySecurity SaaS configuration invalid")?; + return Ok(Config { + modules: document.modules, + workspace_dir: workspace.to_path_buf(), + action_dir: workspace.to_path_buf(), + config_path: candidate, + ..Config::default() + }); + } + Err("TinySecurity SaaS configuration missing from authenticated workspace".into()) +} + +#[cfg(test)] +#[path = "security_config_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/modules/security_config_tests.rs b/crates/openhuman-core/src/modules/security_config_tests.rs new file mode 100644 index 00000000000..53b1dd83a13 --- /dev/null +++ b/crates/openhuman-core/src/modules/security_config_tests.rs @@ -0,0 +1,77 @@ +use super::*; + +#[tokio::test] +async fn saas_configuration_requires_authenticated_dispatch_before_workspace_lookup() { + let workspace = tempfile::tempdir().unwrap(); + std::fs::write( + workspace.path().join("config.toml"), + "[modules]\nenabled = false\n", + ) + .unwrap(); + assert!( + host_config(workspace.path(), true).await.is_err(), + "missing SaaS dispatch scope must not load operator or arbitrary workspace configuration" + ); +} + +#[tokio::test] +async fn saas_scope_rejects_another_tenants_operation_workspace() { + use crate::core::runtime::{CoreContext, DomainSet}; + let tenant = tempfile::tempdir().unwrap(); + let other = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.workspace_dir = tenant.path().into(); + let context = CoreContext::for_test_with_config(DomainSet::full(), config); + CoreContext::scope(context, async { + assert!(host_config(other.path(), true).await.is_err()); + }) + .await; +} + +#[tokio::test] +async fn authenticated_saas_scope_preserves_in_memory_module_settings() { + use crate::core::runtime::{CoreContext, DomainSet}; + let workspace = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.workspace_dir = workspace.path().into(); + config.modules.enabled = false; + let context = CoreContext::for_test_with_config(DomainSet::full(), config); + CoreContext::scope(context, async { + assert!( + !host_config(workspace.path(), true) + .await + .unwrap() + .modules + .enabled + ); + }) + .await; +} + +#[tokio::test] +async fn configless_saas_scope_requires_its_own_file_without_operator_fallback() { + use crate::core::runtime::{CoreContext, DomainSet}; + let owner = tempfile::tempdir().unwrap(); + // An operator/common-parent configuration exists, but this arbitrary + // tenant workspace has no configuration of its own. + std::fs::write( + owner.path().join("config.toml"), + "[modules]\nenabled = true\n", + ) + .unwrap(); + let workspace = owner.path().join("tenant-working-folder"); + std::fs::create_dir(&workspace).unwrap(); + let context = CoreContext::for_test(DomainSet::full(), Some(workspace.clone())); + CoreContext::scope(context, async { + assert!(host_config(&workspace, true).await.is_err()); + std::fs::write( + workspace.join("config.toml"), + "[modules]\nenabled = false\n", + ) + .unwrap(); + let loaded = host_config(&workspace, true).await.unwrap(); + assert!(!loaded.modules.enabled); + assert_eq!(loaded.workspace_dir, workspace); + }) + .await; +} diff --git a/crates/openhuman-core/src/modules/security_native_tests.rs b/crates/openhuman-core/src/modules/security_native_tests.rs new file mode 100644 index 00000000000..542ca393c04 --- /dev/null +++ b/crates/openhuman-core/src/modules/security_native_tests.rs @@ -0,0 +1,405 @@ +use super::*; +use tinybus::{broker::Broker, module::ModuleHost, transport::memory::MemoryBus, Connection}; +use tinysecurity_bus::{PathAccess, PathPolicy, PathTrustedRoot, PathValidationResult}; + +/// The native CI lane loads the real release through its compiled archive pin. +/// An explicitly selected local fixture remains available for module development. +#[tokio::test(flavor = "multi_thread")] +#[ignore = "requires a released host key or an explicitly selected local native fixture"] +async fn attested_native_path_policy_resolves_and_denies_through_host_client() { + let target = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target"); + std::fs::create_dir_all(&target).unwrap(); + let fixture_root = std::env::var_os("OPENHUMAN_TEST_SECURITY_FIXTURE_DIR") + .map(std::path::PathBuf::from) + .unwrap_or_else(|| target.clone()); + assert!( + fixture_root.is_absolute(), + "native fixture root must be absolute" + ); + std::fs::create_dir_all(&fixture_root).unwrap(); + let fixtures = tempfile::tempdir_in(&fixture_root).unwrap(); + restrict_native_fixture(fixtures.path()); + let acting = tempfile::tempdir_in(&target).unwrap(); + let transport = MemoryBus::new(); + let broker = Broker::new(); + let broker_task = broker.spawn(transport.clone()); + let host = ModuleHost::new(broker); + let configuration = serde_json::to_value(ModuleConfig::default()).unwrap(); + let record = if let Ok(host_key) = std::env::var("OPENHUMAN_TEST_SECURITY_RELEASE_HOST") { + let record = *super::super::registry::find(MODULE_ID).unwrap(); + let asset = record + .asset_for(&host_key) + .expect("published fixture platform must be pinned"); + host.load_github_release( + record.release_url, + asset.archive, + Some(asset.sha256), + configuration, + ) + .expect("released module must pass manifest, digest and native admission"); + record + } else { + // Explicit local development fixture. It never supplies production pins. + let library = std::path::PathBuf::from( + std::env::var_os("OPENHUMAN_TEST_SECURITY_MODULE") + .expect("configure a released host key or a local native fixture"), + ); + let name = library.file_name().unwrap().to_str().unwrap(); + let copied = fixtures.path().join(name); + std::fs::copy(&library, &copied).unwrap(); + let digest = tinybus::module::sha256_file(&copied).unwrap(); + std::fs::write( + fixtures.path().join("modules.toml"), + format!("\"{name}\" = \"{digest}\"\n"), + ) + .unwrap(); + host.load_file_with_config(&copied, configuration).unwrap(); + super::super::ModuleRecord { + id: MODULE_ID, + description: "test-only hashed native fixture", + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "test-only", + release_url: "https://example.invalid", + assets: Box::leak( + vec![super::super::PlatformAsset { + host_key: "test-only", + archive: "test-only-library", + sha256: Box::leak(digest.into_boxed_str()), + }] + .into_boxed_slice(), + ), + load: super::super::LoadPolicy::Eager, + } + }; + let client = Connection::connect(transport.connect().await.unwrap()) + .await + .unwrap(); + let proxy = client + .proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE) + .unwrap() + .with_timeout(CALL_TIMEOUT); + tokio::time::timeout(LOAD_TIMEOUT, async { + while !proxy.is_available().await.unwrap() { + tokio::task::yield_now().await; + } + }) + .await + .unwrap(); + let unpinned = super::super::ModuleRecord { + assets: &[], + ..record + }; + assert_eq!( + require_attestation(&proxy, &unpinned).await.unwrap_err(), + "TinySecurity recipient does not match a pinned release" + ); + require_attestation(&proxy, &record).await.unwrap(); + let root = acting.path().canonicalize().unwrap(); + let action = root.join("action"); + std::fs::create_dir(&action).unwrap(); + std::fs::write(action.join("readable.txt"), "fixture").unwrap(); + let policy = PathPolicy { + enabled: true, + workspace_only: true, + workspace_dir: root.join("state"), + action_dir: action.clone(), + home_dir: None, + forbidden_paths: vec![], + trusted_roots: vec![PathTrustedRoot { + path: action.clone(), + access: PathAccess::ReadWrite, + }], + reserved_paths: vec![], + reserved_prefixes: vec![], + readonly_paths: vec![], + reserved_names: vec![], + }; + let mut malformed = policy.clone(); + malformed.action_dir = "relative-root".into(); + assert!(call_register_path_policy(&proxy, malformed).await.is_err()); + let id = call_register_path_policy(&proxy, policy.clone()) + .await + .unwrap(); + assert_eq!( + id, + call_register_path_policy(&proxy, policy.clone()) + .await + .unwrap() + ); + let valid = call_validate_path( + &proxy, + id.clone(), + "readable.txt".into(), + names::methods::VALIDATE_PATH, + ) + .await + .unwrap(); + assert_eq!( + valid, + PathValidationResult::Allowed { + resolved: action.join("readable.txt") + } + ); + let parent = call_validate_path( + &proxy, + id.clone(), + "new.txt".into(), + names::methods::VALIDATE_PARENT, + ) + .await + .unwrap(); + assert_eq!( + parent, + PathValidationResult::Allowed { + resolved: action.join("new.txt") + } + ); + assert!(matches!( + call_validate_path( + &proxy, + id.clone(), + "../outside".into(), + names::methods::VALIDATE_PATH + ) + .await + .unwrap(), + PathValidationResult::Denied { .. } + )); + host.reinitialize( + "tinysecurity-module", + serde_json::to_value(ModuleConfig::default()).unwrap(), + ) + .await + .unwrap(); + assert_eq!( + valid, + call_validate_path( + &proxy, + id.clone(), + "readable.txt".into(), + names::methods::VALIDATE_PATH + ) + .await + .unwrap() + ); + verify_host_translation(&proxy, &root).await; + // Production memoizes immutable registration and attested proxy; the + // warmed validation path uses exactly one confidential bus call per check. + let mut operation_state = ClientState::default(); + // Shared production registration/cache and native response-validation + // helper, warmed once before timing. SDK admission precedes measurement. + call_host_path( + &mut operation_state, + &proxy, + policy.clone(), + "readable.txt", + false, + ) + .await + .unwrap(); + let mut samples = Vec::with_capacity(500); + for _ in 0..500 { + let started = std::time::Instant::now(); + assert!(matches!( + call_host_path( + &mut operation_state, + &proxy, + policy.clone(), + "readable.txt", + false + ) + .await + .unwrap(), + PathValidationResult::Allowed { .. } + )); + samples.push(started.elapsed()); + } + samples.sort_unstable(); + println!("native path client: 500 samples, 1 bus call/sample, shared production scope-cache/validation helper; excludes config-file load and first admission; p50={:?}, p99={:?}; budget p99<50ms", samples[250], samples[494]); + assert!( + samples[494] < Duration::from_millis(50), + "native path p99 exceeds 50ms admission budget" + ); + host.shutdown(Duration::from_secs(1)).await; + assert!( + call_validate_path( + &proxy, + id, + "readable.txt".into(), + names::methods::VALIDATE_PATH + ) + .await + .is_err(), + "stopped module must refuse validation without an in-process fallback" + ); + broker_task.abort(); +} + +#[cfg(not(windows))] +fn restrict_native_fixture(_path: &std::path::Path) {} + +#[cfg(windows)] +fn restrict_native_fixture(path: &std::path::Path) { + // TinyBus correctly rejects a library writable by arbitrary Windows users. + // Protect the fixture directory before copying, so files inherit this ACL. + let script = r#" +$ErrorActionPreference = 'Stop' +$identity = [System.Security.Principal.WindowsIdentity]::GetCurrent() +$security = [System.Security.AccessControl.DirectorySecurity]::new() +$security.SetOwner($identity.User) +$security.SetAccessRuleProtection($true, $false) +foreach ($value in @($identity.User.Value, 'S-1-5-18', 'S-1-5-32-544')) { + $sid = [System.Security.Principal.SecurityIdentifier]::new($value) + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new($sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + [System.Security.AccessControl.InheritanceFlags]'ContainerInherit, ObjectInherit', + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow) + $security.AddAccessRule($rule) +} +Set-Acl -LiteralPath $env:OPENHUMAN_NATIVE_FIXTURE_DIR -AclObject $security +"#; + let status = std::process::Command::new("pwsh") + .args(["-NoProfile", "-NonInteractive", "-Command", script]) + .env("OPENHUMAN_NATIVE_FIXTURE_DIR", path) + .status() + .expect("PowerShell must protect native fixture ACL"); + assert!(status.success(), "native fixture ACL setup failed"); +} + +/// Exercise the product's actual host translation against the loaded native +/// engine, rather than hand-constructing a simplified module scope. +async fn verify_host_translation(proxy: &tinybus::Proxy, root: &std::path::Path) { + use crate::security::{SecurityPolicy, TrustedAccess, TrustedRoot}; + let workspace = root.join("host-workspace"); + let action = root.join("host-action"); + let reference = root.join("host-reference"); + for path in [ + &action, + &reference, + &workspace.join("memory"), + &workspace.join("artifacts/tool-results"), + ] { + std::fs::create_dir_all(path).unwrap(); + } + for path in [ + action.join("document.txt"), + reference.join("read.txt"), + workspace.join("memory/private.txt"), + workspace.join("artifacts/tool-results/output.txt"), + root.join("config.toml"), + ] { + std::fs::write(path, "fixture").unwrap(); + } + let host = SecurityPolicy { + workspace_dir: workspace.clone(), + action_dir: action.clone(), + trusted_roots: vec![ + TrustedRoot { + path: action.to_str().unwrap().into(), + access: TrustedAccess::ReadWrite, + }, + TrustedRoot { + path: reference.to_str().unwrap().into(), + access: TrustedAccess::Read, + }, + // A broad workspace grant permits readable artifact outputs but + // must never override the separately reserved internal state. + TrustedRoot { + path: workspace.to_str().unwrap().into(), + access: TrustedAccess::ReadWrite, + }, + ], + ..SecurityPolicy::default() + }; + let mut state = ClientState::default(); + let scope = host.native_path_policy(); + for (path, parent, allowed) in [ + ("document.txt".to_owned(), false, true), + ("new-document.txt".to_owned(), true, true), + ( + reference.join("read.txt").to_str().unwrap().into(), + false, + true, + ), + ( + reference.join("new.txt").to_str().unwrap().into(), + true, + false, + ), + ( + workspace + .join("memory/private.txt") + .to_str() + .unwrap() + .into(), + false, + false, + ), + ( + root.join("config.toml").to_str().unwrap().into(), + false, + false, + ), + ( + workspace.join("artifacts").to_str().unwrap().into(), + false, + true, + ), + ( + workspace + .join("artifacts/tool-results/output.txt") + .to_str() + .unwrap() + .into(), + false, + true, + ), + ( + workspace + .join("artifacts/tool-results/new.txt") + .to_str() + .unwrap() + .into(), + true, + false, + ), + (".env".into(), false, false), + ("../escape".into(), true, false), + ] { + let result = call_host_path(&mut state, proxy, scope.clone(), &path, parent) + .await + .unwrap(); + assert_eq!(matches!(result, PathValidationResult::Allowed { .. }), allowed, + "host-translated native authorization disagrees for {path}, parent={parent}: {result:?}"); + } + std::fs::create_dir_all(action.join(".ssh")).unwrap(); + std::fs::write(action.join(".ssh/key"), "fixture").unwrap(); + let mut disabled = host; + disabled.enabled = false; + assert!(matches!( + call_host_path( + &mut state, + proxy, + disabled.native_path_policy(), + workspace.join("memory/private.txt").to_str().unwrap(), + false + ) + .await + .unwrap(), + PathValidationResult::Allowed { .. } + )); + assert!(matches!( + call_host_path( + &mut state, + proxy, + disabled.native_path_policy(), + ".ssh/key", + false + ) + .await + .unwrap(), + PathValidationResult::Denied { .. } + )); +} diff --git a/crates/openhuman-core/src/modules/security_tests.rs b/crates/openhuman-core/src/modules/security_tests.rs new file mode 100644 index 00000000000..a3d1ae11f50 --- /dev/null +++ b/crates/openhuman-core/src/modules/security_tests.rs @@ -0,0 +1,161 @@ +use super::*; +use tinysecurity_bus::{AccessTier, DenialReason, Verdict}; + +fn config() -> Config { + let mut config = Config::default(); + config.action_dir = std::env::current_dir().unwrap(); + config.workspace_dir = config.action_dir.join("host-state"); + config +} + +#[test] +fn initialization_does_not_treat_bootstrap_policy_as_an_agents_enabled_policy() { + let mut config = config(); + config.autonomy.enabled = true; + assert_eq!(module_config(&config), ModuleConfig::default()); +} + +#[test] +fn caller_requires_both_authenticated_identity_and_call_identity() { + assert!(VerifiedCaller::from_host(CallerContext::default()).is_err()); + let context = CallerContext { + agent: "operator".into(), + call_id: "host-call".into(), + tier: AccessTier::Read, + ..Default::default() + }; + assert!(VerifiedCaller::from_host(context.clone()).is_ok()); + assert!(VerifiedCaller::from_host(CallerContext { + call_id: " ".into(), + ..context + }) + .is_err()); +} + +#[test] +fn effective_policy_must_match_config_and_supply_all_required_capabilities() { + let config = module_config(&config()); + let mut info = PolicyInfo { + contract_version: tinysecurity_bus::CONTRACT_VERSION, + generation: 1, + policy: config.policy.clone(), + capabilities: names::METHODS.iter().map(|s| (*s).into()).collect(), + }; + assert!(validate_info(&info, &config).is_ok()); + info.policy.enabled = true; + assert!(validate_info(&info, &config).is_err()); + info.policy = config.policy.clone(); + info.capabilities.pop(); + assert!(validate_info(&info, &config).is_err()); + info.capabilities = names::METHODS.iter().map(|s| (*s).into()).collect(); + info.contract_version = (2, 0); + assert!(validate_info(&info, &config).is_err()); + info.contract_version = tinysecurity_bus::CONTRACT_VERSION; + info.generation = 0; + assert!(validate_info(&info, &config).is_err()); +} + +#[test] +fn allow_and_deny_decisions_require_current_generation_and_no_cache_claim() { + for verdict in [ + Verdict::Allow, + Verdict::Deny { + reason: DenialReason::Floor, + }, + ] { + let mut decision = Decision { + generation: 3, + verdict, + cacheable: false, + }; + assert!(validate_decision(&decision, Some(3)).is_ok()); + assert!(validate_decision(&decision, Some(2)).is_err()); + assert!(validate_decision(&decision, None).is_err()); + decision.cacheable = true; + assert!(validate_decision(&decision, Some(3)).is_err()); + } +} + +#[test] +fn attested_digest_must_be_one_of_the_compiled_release_artifacts() { + let record = super::super::ModuleRecord { + id: MODULE_ID, + description: "policy", + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "1.0.0", + release_url: "https://example.invalid", + assets: &[super::super::PlatformAsset { + host_key: "test", + archive: "test.tar.gz", + sha256: "abcdef", + }], + load: super::super::LoadPolicy::Eager, + }; + assert!(pinned(&record, "ABCDEF")); + assert!(!pinned(&record, "fedcba")); + assert!(!pinned(&record, "")); +} + +#[tokio::test] +async fn bus_name_ownership_without_artifact_attestation_is_refused() { + use tinybus::transport::memory::MemoryBus; + let transport = MemoryBus::new(); + tinybus::broker::Broker::new().spawn(transport.clone()); + let attacker = tinybus::Connection::connect(transport.connect().await.unwrap()) + .await + .unwrap(); + attacker.request_name(names::INTERFACE).await.unwrap(); + let host = tinybus::Connection::connect(transport.connect().await.unwrap()) + .await + .unwrap(); + let proxy = host + .proxy(names::INTERFACE, names::OBJECT_PATH, names::INTERFACE) + .unwrap(); + let record = super::super::ModuleRecord { + id: MODULE_ID, + description: "policy", + bus_name: names::INTERFACE, + object_path: names::OBJECT_PATH, + version: "1.0.0", + release_url: "https://example.invalid", + assets: &[], + load: super::super::LoadPolicy::Eager, + }; + assert!(proxy.is_available().await.unwrap()); + assert_eq!( + require_attestation(&proxy, &record).await.unwrap_err(), + "TinySecurity recipient is not attested" + ); +} + +#[test] +fn relative_resolved_module_paths_are_rejected_instead_of_becoming_execution_targets() { + use tinysecurity_bus::{PathErrorCategory, PathValidationResult}; + assert!(validate_path_response(&PathValidationResult::Allowed { + resolved: "relative/file".into() + }) + .is_err()); + assert!(validate_path_response(&PathValidationResult::Allowed { + resolved: std::env::current_dir().unwrap() + }) + .is_ok()); + assert!(validate_path_response(&PathValidationResult::Denied { + category: PathErrorCategory::Protected + }) + .is_ok()); + assert!(serde_json::from_value::( + serde_json::json!({"status":"allowed"}) + ) + .is_err()); +} + +#[tokio::test] +async fn disabled_modules_are_refused_before_using_cached_client_state() { + let mut config = config(); + config.modules.enabled = false; + assert_eq!( + policy_info(&config).await.unwrap_err(), + "TinySecurity modules are disabled" + ); +} diff --git a/crates/openhuman-core/src/sandbox/grants_tests.rs b/crates/openhuman-core/src/sandbox/grants_tests.rs index e490e6c464a..f115644ee2d 100644 --- a/crates/openhuman-core/src/sandbox/grants_tests.rs +++ b/crates/openhuman-core/src/sandbox/grants_tests.rs @@ -27,6 +27,7 @@ fn all(g: &JailGrants) -> Vec { /// A grant reaches a credential store if it IS one, is inside one, or contains /// one (Landlock grants are recursive, so a parent grant exposes the child). fn reaches_credentials(grants: &[PathBuf], home: &Path) -> Option { + let home = canon(home); for cred in [".ssh", ".gnupg", ".aws"] { let cred = home.join(cred); for g in grants { @@ -38,6 +39,31 @@ fn reaches_credentials(grants: &[PathBuf], home: &Path) -> Option { None } +#[test] +fn credential_assertion_recognizes_canonical_directory_file_and_ancestor_grants() { + let home = fake_home(); + for cred in [".ssh", ".gnupg", ".aws"] { + let credential_dir = home.path().join(cred); + let credential_file = credential_dir.join("leaky"); + fs::write(&credential_file, "").unwrap(); + for grant in [ + canon(&credential_dir), + canon(&credential_file), + canon(home.path()), + ] { + // Native Windows canonicalization adds a verbatim prefix to the + // grant. The parent component also exercises normalization on Unix. + for home_path in [home.path().to_path_buf(), home.path().join(".cargo/..")] { + assert_eq!( + reaches_credentials(std::slice::from_ref(&grant), &home_path), + Some(grant.clone()), + "credential grant {grant:?} must be detected for home {home_path:?}" + ); + } + } + } +} + #[test] fn credential_dirs_are_never_granted_by_default() { let home = fake_home(); @@ -68,7 +94,11 @@ fn credential_dirs_are_never_granted_even_when_extras_ask() { #[test] fn credential_floor_holds_for_a_symlink_into_a_credential_dir() { let home = fake_home(); + #[cfg(unix)] std::os::unix::fs::symlink(home.path().join(".ssh"), home.path().join("innocent")).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_dir(home.path().join(".ssh"), home.path().join("innocent")) + .unwrap(); let cfg = LocalJailConfig { extra_read_only: vec!["~/innocent".into()], ..LocalJailConfig::default() @@ -233,7 +263,11 @@ fn gitconfig_symlink_and_include_targets_are_canonicalized() { [include]\n path = ~/.ssh/leaky\n", ) .unwrap(); + #[cfg(unix)] std::os::unix::fs::symlink(dots.join("gitconfig"), home.path().join(".gitconfig")).unwrap(); + #[cfg(windows)] + std::os::windows::fs::symlink_file(dots.join("gitconfig"), home.path().join(".gitconfig")) + .unwrap(); // The credential include must be dropped even though the file exists. fs::write(home.path().join(".ssh/leaky"), "").unwrap(); diff --git a/crates/openhuman-core/src/security/README.md b/crates/openhuman-core/src/security/README.md index 7aed06444f2..c20570cfbba 100644 --- a/crates/openhuman-core/src/security/README.md +++ b/crates/openhuman-core/src/security/README.md @@ -4,8 +4,31 @@ The trust boundary of the core. This folder decides whether an agent may run a command or touch a path, parks tool calls that need a human yes or no, stores credentials and secrets, scrubs secrets and PII out of anything persisted, and guards the RPC listener when it binds beyond loopback. `security/mod.rs` calls -it the kernel security family. None of its submodules is feature-gated: every -build carries all of it. +it the kernel security family. The `security-module` feature moves asynchronous +filesystem authorization into the separately loaded TinySecurity native module. + +## Native filesystem policy + +With `security-module`, `SecurityPolicy::validate_path` and +`validate_parent_path` use the typed host client in `modules/security.rs`. +`policy/native_paths.rs` translates trusted roots, the acting directory, +internal-state reservations, and the current turn's workspace grant into an +immutable scope. Scope registration deduplicates the whole policy; one agent +cannot replace another agent's settings through global module reinitialization. + +The host links only `tinysecurity-bus`. The engine canonicalizes paths and +checks read/write grants in the native module. A missing module, an untrusted +recipient, a timeout, or a malformed response denies access without a local +fallback. Synchronous lexical checks remain host preflights during this phase; +they do not substitute for the asynchronous I/O authorization. + +Production admission pins TinySecurity v0.2.2 and its published archive checksums +in the module registry. `scripts/ci/security-native-fixture.sh` loads those +released archives and invokes their admission, path, and latency tests. Explicit +local fixtures remain available for development; their digests never become +production release pins. Shell policy, +approvals, redaction, and crypto continue to use their existing host engines +until their own migration phases are implemented and verified. Callers are spread across the core. Every acting tool consults `SecurityPolicy` before it runs, the agent harness asks the approval gate diff --git a/crates/openhuman-core/src/security/policy/mod.rs b/crates/openhuman-core/src/security/policy/mod.rs index d674b209a65..bfcae91c835 100644 --- a/crates/openhuman-core/src/security/policy/mod.rs +++ b/crates/openhuman-core/src/security/policy/mod.rs @@ -4,6 +4,8 @@ mod command_checks; mod enforcement; +#[cfg(feature = "security-module")] +mod native_paths; mod path_checks; mod types; diff --git a/crates/openhuman-core/src/security/policy/native_paths.rs b/crates/openhuman-core/src/security/policy/native_paths.rs new file mode 100644 index 00000000000..3b502accf5b --- /dev/null +++ b/crates/openhuman-core/src/security/policy/native_paths.rs @@ -0,0 +1,127 @@ +//! Product configuration translation for native TinySecurity filesystem scopes. +//! +//! Only async I/O validators migrate here. Synchronous lexical helpers remain +//! host preflights; they cannot grant I/O permission in place of native validation. + +use std::path::PathBuf; + +use tinysecurity_bus::{ + PathAccess, PathErrorCategory, PathPolicy, PathReservation, PathReservedPrefix, + PathTrustedRoot, PathValidationResult, +}; + +use super::types::{ + SecurityPolicy, TrustedAccess, ACCOUNT_CONFIG_FILE, ARTIFACTS_DIR, ARTIFACT_TOOL_RESULTS_DIR, + POLICY_BLOCKED_MARKER, WORKSPACE_INTERNAL_DIRS, WORKSPACE_INTERNAL_FILES, + WORKSPACE_INTERNAL_PREFIXES, +}; + +impl SecurityPolicy { + /// Capture host-owned settings and the task-local turn grant before any await. + /// Registration deduplicates the complete immutable value, so changed roots + /// or policy settings cannot reuse a stale scope from a cloned policy. + pub(crate) fn native_path_policy(&self) -> PathPolicy { + let mut trusted_roots: Vec<_> = self + .trusted_roots + .iter() + .map(|root| PathTrustedRoot { + path: PathBuf::from(self.expand_tilde(&root.path)), + access: match root.access { + TrustedAccess::Read => PathAccess::ReadOnly, + TrustedAccess::ReadWrite => PathAccess::ReadWrite, + }, + }) + .collect(); + if let Some(path) = crate::agent::turn_workspace::current() { + trusted_roots.push(PathTrustedRoot { + path, + access: PathAccess::ReadWrite, + }); + } + let mut reserved_paths: Vec<_> = WORKSPACE_INTERNAL_DIRS + .iter() + .chain(WORKSPACE_INTERNAL_FILES.iter()) + .map(|name| PathReservation { + path: self.workspace_dir.join(name), + exceptions: Vec::new(), + children_only: false, + }) + .collect(); + if let Some(account_dir) = self.workspace_dir.parent() { + reserved_paths.push(PathReservation { + path: account_dir.join(ACCOUNT_CONFIG_FILE), + exceptions: Vec::new(), + children_only: false, + }); + } + let tool_results = self + .workspace_dir + .join(ARTIFACTS_DIR) + .join(ARTIFACT_TOOL_RESULTS_DIR); + reserved_paths.push(PathReservation { + path: self.workspace_dir.join(ARTIFACTS_DIR), + exceptions: vec![tool_results.clone()], + children_only: true, + }); + PathPolicy { + enabled: self.enabled, + workspace_only: self.workspace_only, + workspace_dir: self.workspace_dir.clone(), + action_dir: self.action_dir.clone(), + home_dir: dirs::home_dir(), + forbidden_paths: self.forbidden_paths.clone(), + trusted_roots, + reserved_paths, + reserved_prefixes: WORKSPACE_INTERNAL_PREFIXES + .iter() + .map(|prefix| PathReservedPrefix { + root: self.workspace_dir.clone(), + prefix: (*prefix).to_owned(), + }) + .collect(), + readonly_paths: vec![tool_results], + reserved_names: WORKSPACE_INTERNAL_FILES + .iter() + .map(|name| (*name).to_owned()) + .collect(), + } + } + + pub(super) async fn validate_native_path( + &self, + path: &str, + parent: bool, + ) -> Result { + let scope = self.native_path_policy(); + let result = crate::modules::security::validate_host_path(scope, path, parent).await; + project_native_result(path, result) + } +} + +/// Project stable native categories into the existing host tool error contract. +fn project_native_result( + path: &str, + result: Result, +) -> Result { + match result { + Ok(PathValidationResult::Allowed { resolved }) if resolved.is_absolute() => Ok(resolved), + Ok(PathValidationResult::Allowed { .. }) => Err(format!( + "{POLICY_BLOCKED_MARKER} TinySecurity returned an invalid resolved path" + )), + Ok(PathValidationResult::Denied { category }) => match category { + PathErrorCategory::Protected | PathErrorCategory::PolicyDenied => Err(format!( + "{POLICY_BLOCKED_MARKER} Path not allowed by security policy: {path}. Do not \ + retry this path; use an allowed location (the workspace or a granted folder)." + )), + PathErrorCategory::Resolution => Err(format!("Failed to resolve path '{path}'")), + PathErrorCategory::InvalidPath => Err(format!("Invalid path: {path}")), + }, + Err(_) => Err(format!( + "{POLICY_BLOCKED_MARKER} TinySecurity path authorization unavailable; file access denied" + )), + } +} + +#[cfg(test)] +#[path = "native_paths_tests.rs"] +mod tests; diff --git a/crates/openhuman-core/src/security/policy/native_paths_tests.rs b/crates/openhuman-core/src/security/policy/native_paths_tests.rs new file mode 100644 index 00000000000..d051f6a0169 --- /dev/null +++ b/crates/openhuman-core/src/security/policy/native_paths_tests.rs @@ -0,0 +1,157 @@ +use super::*; +use crate::security::TrustedRoot; + +fn policy() -> SecurityPolicy { + SecurityPolicy { + workspace_dir: PathBuf::from("/account/workspace"), + action_dir: PathBuf::from("/project"), + trusted_roots: vec![ + TrustedRoot { + path: "/project".into(), + access: TrustedAccess::ReadWrite, + }, + TrustedRoot { + path: "/reference".into(), + access: TrustedAccess::Read, + }, + ], + forbidden_paths: vec!["private".into(), "~/secrets".into()], + ..SecurityPolicy::default() + } +} + +#[test] +fn scope_preserves_product_internal_state_and_readonly_output_exception() { + let scope = policy().native_path_policy(); + for name in [ + "memory", + "sessions", + "personalities", + "vault", + "core.token", + ".env", + "SOUL.md", + ] { + assert!( + scope + .reserved_paths + .iter() + .any(|entry| entry.path == scope.workspace_dir.join(name) + && entry.exceptions.is_empty() + && !entry.children_only), + "{name}" + ); + } + assert!(scope + .reserved_paths + .iter() + .any(|entry| entry.path == PathBuf::from("/account/config.toml"))); + let artifacts = scope + .reserved_paths + .iter() + .find(|entry| entry.path == scope.workspace_dir.join("artifacts")) + .unwrap(); + assert!(artifacts.children_only); + let outputs = scope.workspace_dir.join("artifacts/tool-results"); + assert_eq!(artifacts.exceptions, [outputs.clone()]); + assert_eq!(scope.readonly_paths, [outputs]); + assert_eq!( + scope + .reserved_prefixes + .iter() + .map(|entry| entry.prefix.as_str()) + .collect::>(), + ["memory-", "memory_tree-", "session_raw-"] + ); + assert!(scope.reserved_names.iter().any(|name| name == ".env")); +} + +#[test] +fn changed_agent_settings_produce_distinct_immutable_native_scope() { + let mut host = policy(); + let first = host.native_path_policy(); + assert_eq!(first.trusted_roots[0].access, PathAccess::ReadWrite); + assert_eq!(first.trusted_roots[1].access, PathAccess::ReadOnly); + assert_eq!(first.forbidden_paths, host.forbidden_paths); + host.enabled = false; + host.workspace_only = false; + host.action_dir = PathBuf::from("/different-project"); + host.trusted_roots[0].access = TrustedAccess::Read; + let changed = host.native_path_policy(); + assert_ne!(first, changed); + assert!(!changed.enabled); + assert!(!changed.workspace_only); + assert_eq!(changed.action_dir, host.action_dir); + assert_eq!(changed.trusted_roots[0].access, PathAccess::ReadOnly); +} + +#[tokio::test] +async fn task_local_turn_root_is_included_without_leaking_into_other_scopes() { + let host = policy(); + let root = PathBuf::from("/workflow-turn"); + let outside = host.native_path_policy(); + let inside = crate::agent::turn_workspace::with_workspace(root.clone(), async { + host.native_path_policy() + }) + .await; + assert_eq!( + inside.trusted_roots.last().unwrap(), + &PathTrustedRoot { + path: root, + access: PathAccess::ReadWrite + } + ); + assert_eq!(inside.trusted_roots.len(), outside.trusted_roots.len() + 1); + assert_eq!(host.native_path_policy(), outside); +} + +#[test] +fn native_transport_faults_and_malformed_permissions_fail_closed() { + for result in [ + Err("timeout with sensitive provider detail".into()), + Ok(PathValidationResult::Allowed { + resolved: PathBuf::from("relative"), + }), + ] { + let error = project_native_result("readme.txt", result).unwrap_err(); + assert!(error.contains(POLICY_BLOCKED_MARKER)); + assert!(error.contains("TinySecurity")); + assert!(!error.contains("sensitive provider")); + } +} + +#[test] +fn native_denial_categories_preserve_the_host_tool_error_contract() { + for category in [ + PathErrorCategory::Protected, + PathErrorCategory::PolicyDenied, + ] { + let error = + project_native_result("blocked.txt", Ok(PathValidationResult::Denied { category })) + .unwrap_err(); + assert!(error.contains(POLICY_BLOCKED_MARKER)); + assert!(error.contains("Do not retry")); + } + for (category, expected) in [ + (PathErrorCategory::Resolution, "Failed to resolve path"), + (PathErrorCategory::InvalidPath, "Invalid path"), + ] { + assert!(project_native_result( + "missing.txt", + Ok(PathValidationResult::Denied { category }) + ) + .unwrap_err() + .contains(expected)); + } + let path = std::env::current_dir().unwrap().join("readme.txt"); + assert_eq!( + project_native_result( + "readme.txt", + Ok(PathValidationResult::Allowed { + resolved: path.clone() + }) + ) + .unwrap(), + path + ); +} diff --git a/crates/openhuman-core/src/security/policy/path_checks.rs b/crates/openhuman-core/src/security/policy/path_checks.rs index 954f80a5f24..6daa1980783 100644 --- a/crates/openhuman-core/src/security/policy/path_checks.rs +++ b/crates/openhuman-core/src/security/policy/path_checks.rs @@ -1,9 +1,11 @@ use std::path::{Path, PathBuf}; -use super::types::{SecurityPolicy, TrustedAccess, POLICY_BLOCKED_MARKER}; +#[cfg(any(test, not(feature = "security-module")))] +use super::types::POLICY_BLOCKED_MARKER; +use super::types::{SecurityPolicy, TrustedAccess}; use super::types::{ ACCOUNT_CONFIG_FILE, ARTIFACTS_DIR, ARTIFACT_TOOL_RESULTS_DIR, WORKSPACE_INTERNAL_DIRS, - WORKSPACE_INTERNAL_FILES, + WORKSPACE_INTERNAL_FILES, WORKSPACE_INTERNAL_PREFIXES, }; impl SecurityPolicy { @@ -216,6 +218,7 @@ impl SecurityPolicy { /// Falls back to the raw `workspace_dir` if `canonicalize` fails (e.g. /// during early startup or in tests where the workspace doesn't exist on /// disk), matching the inline behavior the callers used before the cache. + #[cfg(any(test, not(feature = "security-module")))] pub(super) async fn workspace_root(&self) -> PathBuf { self.canonical_workspace .get_or_init(|| async { @@ -227,14 +230,14 @@ impl SecurityPolicy { .clone() } - /// Synchronous counterpart to [`workspace_root`], hydrating the **same** + /// Synchronous counterpart to the legacy async workspace-root helper, hydrating the **same** /// `canonical_workspace` cache via `OnceCell`'s sync `get`/`set`. /// /// The sync path validators (`is_path_string_allowed`, /// `is_resolved_path_allowed_for`) run on every file tool call and each /// previously re-invoked `self.workspace_dir.canonicalize()` — one /// `stat(2)` + symlink walk on the same immutable input per call. They - /// cannot `.await` [`workspace_root`], so they reach the cache through this + /// cannot await the legacy async workspace-root helper, so they reach the cache through this /// helper. /// /// # Why one cell can serve both, and why a lost `set` is safe @@ -259,7 +262,7 @@ impl SecurityPolicy { /// /// Fallback to the raw `workspace_dir` on canonicalize failure matches the /// inline behavior these callers used before, and the async - /// [`workspace_root`] — including under the race, since the fallback is the + /// legacy async workspace-root helper — including under the race, since the fallback is the /// same immutable `workspace_dir` on both sides. pub(super) fn workspace_root_sync(&self) -> PathBuf { if let Some(cached) = self.canonical_workspace.get() { @@ -279,6 +282,12 @@ impl SecurityPolicy { /// Validate a path for file I/O: string checks, canonicalize, workspace containment, /// and forbidden-path check on the resolved path. /// Returns the canonical `PathBuf` on success. + #[cfg(feature = "security-module")] + pub async fn validate_path(&self, path: &str) -> Result { + self.validate_native_path(path, false).await + } + + #[cfg(not(feature = "security-module"))] pub async fn validate_path(&self, path: &str) -> Result { if !self.is_path_string_allowed(path) { return Err(format!( @@ -316,6 +325,12 @@ impl SecurityPolicy { /// Does NOT require the parent directory to exist — walks up to the deepest /// existing ancestor and checks that for symlink escapes. /// Returns the canonical full path (parent resolved + filename appended). + #[cfg(feature = "security-module")] + pub async fn validate_parent_path(&self, path: &str) -> Result { + self.validate_native_path(path, true).await + } + + #[cfg(not(feature = "security-module"))] pub async fn validate_parent_path(&self, path: &str) -> Result { if !self.is_path_string_allowed(path) { return Err(format!( @@ -417,13 +432,11 @@ impl SecurityPolicy { }; let component = first_component.as_ref(); if WORKSPACE_INTERNAL_DIRS.contains(&component) - || ["memory-", "memory_tree-", "session_raw-"] - .iter() - .any(|prefix| { - component - .strip_prefix(prefix) - .is_some_and(|s| !s.is_empty()) - }) + || WORKSPACE_INTERNAL_PREFIXES.iter().any(|prefix| { + component + .strip_prefix(prefix) + .is_some_and(|s| !s.is_empty()) + }) { return true; } @@ -578,6 +591,7 @@ impl SecurityPolicy { /// Check `resolved` against every entry in `forbidden_paths`, resolving relative /// entries against `workspace_root`. Absolute entries whose prefix IS the workspace /// root are skipped — the workspace containment check already covers them. + #[cfg(not(feature = "security-module"))] pub(super) fn check_resolved_against_forbidden( &self, resolved: &Path, @@ -625,3 +639,7 @@ impl SecurityPolicy { Ok(()) } } + +#[cfg(all(test, feature = "security-module"))] +#[path = "path_checks_native_tests.rs"] +mod native_tests; diff --git a/crates/openhuman-core/src/security/policy/path_checks_native_tests.rs b/crates/openhuman-core/src/security/policy/path_checks_native_tests.rs new file mode 100644 index 00000000000..1d1147ebe8e --- /dev/null +++ b/crates/openhuman-core/src/security/policy/path_checks_native_tests.rs @@ -0,0 +1,49 @@ +use super::*; + +/// An ordinary readable file must not bypass the missing native policy module. +#[tokio::test] +async fn native_path_validation_never_falls_back_when_module_is_unavailable() { + // The production client's faults intentionally latch for the process. Run + // this missing-module case in a child so it cannot poison other bus tests. + const CHILD: &str = "OPENHUMAN_NATIVE_PATH_UNAVAILABLE_TEST_CHILD"; + if std::env::var_os(CHILD).is_none() { + let output = std::process::Command::new(std::env::current_exe().unwrap()) + .args(["--exact", "security::policy::path_checks::native_tests::native_path_validation_never_falls_back_when_module_is_unavailable", "--nocapture"]) + .env(CHILD, "1") + .output() + .unwrap(); + assert!( + output.status.success(), + "{}{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + return; + } + let dir = tempfile::tempdir().unwrap(); + let workspace = dir.path().join("workspace"); + let action = dir.path().join("project"); + std::fs::create_dir_all(&workspace).unwrap(); + std::fs::create_dir_all(&action).unwrap(); + std::fs::write(action.join("readme.txt"), "public file").unwrap(); + std::fs::write( + dir.path().join("config.toml"), + "[modules]\nenabled = false\n", + ) + .unwrap(); + let policy = SecurityPolicy { + enabled: false, + workspace_dir: workspace, + action_dir: action, + ..SecurityPolicy::default() + }; + let error = policy.validate_path("readme.txt").await.unwrap_err(); + assert!(error.contains(POLICY_BLOCKED_MARKER), "{error}"); + assert!(error.contains("TinySecurity"), "{error}"); + let error = policy + .validate_parent_path("new-file.txt") + .await + .unwrap_err(); + assert!(error.contains(POLICY_BLOCKED_MARKER), "{error}"); + assert!(error.contains("TinySecurity"), "{error}"); +} diff --git a/crates/openhuman-core/src/security/policy/policy_trusted_roots_tests.rs b/crates/openhuman-core/src/security/policy/policy_trusted_roots_tests.rs index c0df8164bf1..dfad694689d 100644 --- a/crates/openhuman-core/src/security/policy/policy_trusted_roots_tests.rs +++ b/crates/openhuman-core/src/security/policy/policy_trusted_roots_tests.rs @@ -538,6 +538,7 @@ fn from_config_does_not_duplicate_user_granted_projects_root() { /// that result. This test pins the contract: the cell starts empty, is /// populated after the first `validate_path` call, and stays populated (same /// value) across subsequent calls — i.e. only one canonicalize per policy. +#[cfg(not(feature = "security-module"))] #[tokio::test] async fn validate_path_caches_canonical_workspace_root() { let tmp = tempfile::tempdir().unwrap(); @@ -657,6 +658,7 @@ async fn workspace_root_sync_hydrates_and_shares_the_async_cache() { /// `validate_parent_path` shares the same cache as `validate_path` — both go /// through `workspace_root()`. Hydrating via either entry point must be /// observable from the other. +#[cfg(not(feature = "security-module"))] #[tokio::test] async fn validate_parent_path_uses_same_cache_as_validate_path() { let tmp = tempfile::tempdir().unwrap(); diff --git a/crates/openhuman-core/src/security/policy/types.rs b/crates/openhuman-core/src/security/policy/types.rs index f658e5e7219..638e9e2488f 100644 --- a/crates/openhuman-core/src/security/policy/types.rs +++ b/crates/openhuman-core/src/security/policy/types.rs @@ -190,6 +190,10 @@ pub(super) const WORKSPACE_INTERNAL_DIRS: &[&str] = &[ "tinyplace", ]; +/// Per-profile internal-state directory families under the workspace. +pub(super) const WORKSPACE_INTERNAL_PREFIXES: &[&str] = + &["memory-", "memory_tree-", "session_raw-"]; + /// The artifact store under `workspace_dir`. Its per-artifact directories are /// internal state (see `is_workspace_internal_path`); only /// [`ARTIFACT_TOOL_RESULTS_DIR`] inside it stays agent-readable. diff --git a/crates/openhuman-core/src/web3/wallet/rpc_tests.rs b/crates/openhuman-core/src/web3/wallet/rpc_tests.rs index 19e28c2637b..35dadb8ff3e 100644 --- a/crates/openhuman-core/src/web3/wallet/rpc_tests.rs +++ b/crates/openhuman-core/src/web3/wallet/rpc_tests.rs @@ -12,3 +12,18 @@ fn redact_rpc_url_strips_path_and_query() { fn redact_rpc_url_handles_invalid_values() { assert_eq!(redact_rpc_url("not a url"), ""); } + +#[test] +fn shared_security_url_corpus_pins_wallet_log_redactor() { + let corpus: serde_json::Value = serde_json::from_str(include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../tests/fixtures/security-redaction-corpus.json" + ))) + .unwrap(); + for case in corpus["urls"].as_array().unwrap() { + assert_eq!( + redact_rpc_url(case["input"].as_str().unwrap()), + case["wallet"] + ); + } +} diff --git a/crates/openhuman-embed/Cargo.toml b/crates/openhuman-embed/Cargo.toml index 5c3046b9f00..9f1f3f3f29c 100644 --- a/crates/openhuman-embed/Cargo.toml +++ b/crates/openhuman-embed/Cargo.toml @@ -19,6 +19,7 @@ documents = ["openhuman-core/documents"] hosting = ["openhuman-core/hosting"] tinymemes = ["openhuman-core/tinymemes"] modules = ["openhuman-core/modules"] +security-module = ["openhuman-core/security-module"] voice = ["openhuman-core/voice"] web3 = ["openhuman-core/web3"] # Storage drivers for `[storage] url` (see the core `storage` domain). @@ -82,6 +83,7 @@ url = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] +rand = "0.10" async-trait = "0.1" tinymemory-api = { path = "../../vendor/tinymemory/crates/tinymemory-api", features = ["conformance"] } dirs = "6" diff --git a/crates/openhuman-embed/examples/README.md b/crates/openhuman-embed/examples/README.md index c24342786b7..9922e16ac64 100644 --- a/crates/openhuman-embed/examples/README.md +++ b/crates/openhuman-embed/examples/README.md @@ -13,6 +13,7 @@ - [A per-agent post-turn hook observes completed turns](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/hooks.rs): A per-agent post-turn hook observes completed turns. (offline; optional live via OPENHUMAN_EXAMPLE_LIVE=1 and BASE_URL/API_KEY/MODEL.) - [Host tools and HostOnly keep the advertised tool catalog exact](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/host_tools.rs): Host tools and HostOnly keep the advertised tool catalog exact. (offline with loopback stubs; no live path.) - [Lean runtime without background services](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/lean_headless.rs): Lean runtime without background services. (offline with loopback stubs; optional live via OPENHUMAN_EXAMPLE_LIVE.) +- [Linux agent fleet memory and latency](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/linux_fleet.rs): Measure retained runtime-owned agents using loopback inference and two worker threads. (offline on Linux; use a fresh constrained cgroup for release measurements.) - [Connect an actual MCP protocol stub over loopback](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/mcp.rs): Connect an actual MCP protocol stub over loopback. (offline with loopback stubs; no live path.; feature: mcp) - [Tenant scoped memory facade with an in-memory engine](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/memory.rs): Tenant scoped memory facade with an in-memory engine. (offline with loopback stubs; no live path.) - [SaaS profiles isolate conversation history](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/profiles.rs): SaaS profiles isolate conversation history. (offline with loopback stubs; no live path.) diff --git a/crates/openhuman-embed/tests/common/mod.rs b/crates/openhuman-embed/tests/common/mod.rs index 22d75e8fc05..f25a3199f2f 100644 --- a/crates/openhuman-embed/tests/common/mod.rs +++ b/crates/openhuman-embed/tests/common/mod.rs @@ -64,14 +64,26 @@ pub fn offline_config() -> Config { /// sub-agent and aborts the whole process, so building it the documented way is /// both what the test needs and a check that the documented way works. pub fn runtime() -> tokio::runtime::Runtime { - // Hosts initialize encryption before an API key is persisted by runtime - // boot. The runner supplies a disposable master key for headless tests. - if std::env::var_os("OPENHUMAN_KEYRING_MASTER_KEY").is_some() { - static KEY_ROOT: std::sync::OnceLock = std::sync::OnceLock::new(); - let root = KEY_ROOT.get_or_init(|| tempfile::tempdir().expect("fixture key root")); - std::env::set_var("OPENHUMAN_WORKSPACE", root.path()); - openhuman_embed::process::init_master_key().expect("fixture master key initializes"); - } + static KEYRING: std::sync::OnceLock = std::sync::OnceLock::new(); + let directory = KEYRING.get_or_init(|| tempfile::tempdir().expect("scratch keyring workspace")); + runtime_with_keyring(directory.path()) +} + +/// Build the tuned runtime with the fixture's intended encrypted keyring root. +/// SaaS fixtures supply their operator workspace, which their boot guard requires. +pub fn runtime_with_keyring(workspace: &std::path::Path) -> tokio::runtime::Runtime { + // Core is a normal dependency here, so its unit-test keyring fallback is + // absent. Supply a fresh headless key before any credential operation. + static KEY: std::sync::Once = std::sync::Once::new(); + KEY.call_once(|| { + let bytes: [u8; 32] = rand::random(); + let key: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "encrypted_file"); + std::env::remove_var("OPENHUMAN_KEYRING_MASTER_KEY_FILE"); + std::env::set_var("OPENHUMAN_KEYRING_MASTER_KEY", key); + }); + openhuman_core::security::keyring::init_workspace(workspace); + openhuman_core::security::keyring::init_master_key().expect("headless test master key"); runtime_without_master_key() } diff --git a/crates/openhuman-embed/tests/saas_profiles.rs b/crates/openhuman-embed/tests/saas_profiles.rs index f5053751438..7611b12d635 100644 --- a/crates/openhuman-embed/tests/saas_profiles.rs +++ b/crates/openhuman-embed/tests/saas_profiles.rs @@ -11,8 +11,7 @@ mod common; use std::time::Duration; use common::{ - chat_requests, echo_inference, last_user_message, runtime_without_master_key as runtime, - PointedTransport, + chat_requests, echo_inference, last_user_message, runtime_with_keyring, PointedTransport, }; use openhuman_embed::profiles::ProfileCredentialKind; use openhuman_embed::{ @@ -26,16 +25,16 @@ fn profiles_are_isolated_held_and_relayed() { let _ = env_logger::builder().is_test(true).try_init(); // On a worker thread: a turn dispatched from the test thread itself would // overflow its default stack. - let rt = runtime(); - rt.block_on(async { tokio::spawn(scenario()).await }) + let root = tempfile::tempdir().expect("root"); + let rt = runtime_with_keyring(&root.path().join("operator/workspace")); + rt.block_on(async { tokio::spawn(scenario(root)).await }) .expect("scenario"); } -async fn scenario() { +async fn scenario(root: tempfile::TempDir) { let inference = echo_inference().await; PointedTransport::install(&inference.uri()); - let root = tempfile::tempdir().expect("root"); let mut config = SaasConfig::new(root.path()); config.max_profiles_open = 4; let profiles = ProfileRuntime::build(config).await.expect("boot"); diff --git a/crates/openhuman-rpc/Cargo.toml b/crates/openhuman-rpc/Cargo.toml index c96550044ff..83318ccaef2 100644 --- a/crates/openhuman-rpc/Cargo.toml +++ b/crates/openhuman-rpc/Cargo.toml @@ -52,6 +52,7 @@ documents = ["openhuman-tinyhumans/documents"] hosting = ["openhuman-tinyhumans/hosting"] tinymemes = ["openhuman-tinyhumans/tinymemes"] modules = ["openhuman-tinyhumans/modules"] +security-module = ["openhuman-tinyhumans/security-module"] voice = ["openhuman-tinyhumans/voice"] web3 = ["openhuman-tinyhumans/web3"] # Storage drivers for `[storage] url` (see the core `storage` domain and diff --git a/crates/openhuman-rpc/src/server/auth_tests.rs b/crates/openhuman-rpc/src/server/auth_tests.rs index ec6cc26039c..c728f741955 100644 --- a/crates/openhuman-rpc/src/server/auth_tests.rs +++ b/crates/openhuman-rpc/src/server/auth_tests.rs @@ -71,10 +71,37 @@ fn is_external_inference_path_matches_only_v1_routes() { #[test] fn verify_external_inference_bearer_for_config_accepts_stored_key() { + const CHILD: &str = "OPENHUMAN_TEST_STORED_KEY_CHILD"; + if std::env::var_os(CHILD).is_none() { + // Core is a normal dependency: its keyring state is process-wide. + // An isolated child cannot inherit another test's cached keychain failure. + let workspace = tempfile::tempdir().expect("scratch keyring workspace"); + let bytes: [u8; 32] = rand::random(); + let key: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + let status = std::process::Command::new(std::env::current_exe().unwrap()) + .args([ + "--exact", + "server::auth::tests::verify_external_inference_bearer_for_config_accepts_stored_key", + "--nocapture", + ]) + .env(CHILD, "1") + .env("OPENHUMAN_WORKSPACE", workspace.path()) + .env("OPENHUMAN_KEYRING_BACKEND", "encrypted_file") + .env("OPENHUMAN_KEYRING_MASTER_KEY", key) + .env_remove("OPENHUMAN_KEYRING_MASTER_KEY_FILE") + .status() + .expect("run isolated stored-key assertions"); + assert!(status.success(), "stored-key child failed: {status}"); + return; + } + crate::embed::process::init_master_key().expect("headless test master key"); // Keep a session_store test from installing a storage backend mid-test. let _slot = crate::STORAGE_SLOT_TEST_LOCK.blocking_lock(); let tmp = tempfile::tempdir().unwrap(); + let workspace = std::path::PathBuf::from(std::env::var_os("OPENHUMAN_WORKSPACE").unwrap()); let config = Config { + workspace_dir: workspace.clone(), + action_dir: workspace, config_path: tmp.path().join("config.toml"), ..Default::default() }; diff --git a/crates/openhuman-tinyhumans/Cargo.toml b/crates/openhuman-tinyhumans/Cargo.toml index bf2334c3729..eef2c7a9bcc 100644 --- a/crates/openhuman-tinyhumans/Cargo.toml +++ b/crates/openhuman-tinyhumans/Cargo.toml @@ -25,6 +25,7 @@ documents = ["openhuman-embed/documents"] hosting = ["openhuman-embed/hosting"] tinymemes = ["openhuman-embed/tinymemes"] modules = ["openhuman-embed/modules"] +security-module = ["openhuman-embed/security-module"] voice = ["openhuman-embed/voice"] web3 = ["openhuman-embed/web3"] # Storage drivers for `[storage] url` (see the core `storage` domain). diff --git a/crates/openhuman-tinyhumans/src/hosted/client_tests.rs b/crates/openhuman-tinyhumans/src/hosted/client_tests.rs index 07b6bb421f6..aadf59f1822 100644 --- a/crates/openhuman-tinyhumans/src/hosted/client_tests.rs +++ b/crates/openhuman-tinyhumans/src/hosted/client_tests.rs @@ -2,13 +2,13 @@ use super::*; use openhuman_embed::__host::core::observability::{ expected_error_kind, is_api_key_rejected_message, is_session_expired_message, }; -use openhuman_embed::__host::security::credentials::{AuthService, APP_SESSION_PROVIDER}; use serde_json::json; use tempfile::TempDir; use wiremock::matchers::{header, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; fn test_config(tmp: &TempDir, api_url: &str) -> Config { + crate::hosted::test_support::init_keyring(); Config { workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), @@ -18,17 +18,7 @@ fn test_config(tmp: &TempDir, api_url: &str) -> Config { } } -fn store_session(config: &Config, token: &str) { - AuthService::from_config(config) - .store_provider_token( - APP_SESSION_PROVIDER, - "default", - token, - std::collections::HashMap::new(), - true, - ) - .expect("store session token"); -} +use crate::hosted::test_support::store_session; #[test] fn backend_origin_strips_path_query_and_fragment() { diff --git a/crates/openhuman-tinyhumans/src/hosted/referral/ops_tests.rs b/crates/openhuman-tinyhumans/src/hosted/referral/ops_tests.rs index 676a566481d..77638ed1f1b 100644 --- a/crates/openhuman-tinyhumans/src/hosted/referral/ops_tests.rs +++ b/crates/openhuman-tinyhumans/src/hosted/referral/ops_tests.rs @@ -3,13 +3,11 @@ use axum::{ routing::{get, post}, Json, Router, }; -use openhuman_embed::__host::security::credentials::{ - AuthService, APP_SESSION_PROVIDER, DEFAULT_AUTH_PROFILE_NAME, -}; use serde_json::json; use tempfile::TempDir; fn test_config(tmp: &TempDir) -> Config { + crate::hosted::test_support::init_keyring(); Config { workspace_dir: tmp.path().join("workspace"), action_dir: tmp.path().join("workspace"), @@ -18,18 +16,6 @@ fn test_config(tmp: &TempDir) -> Config { } } -fn store_session_token(config: &Config, token: &str) { - AuthService::from_config(config) - .store_provider_token( - APP_SESSION_PROVIDER, - DEFAULT_AUTH_PROFILE_NAME, - token, - std::collections::HashMap::new(), - true, - ) - .expect("store token"); -} - async fn spawn_mock(app: Router) -> String { let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); @@ -56,7 +42,7 @@ fn config_with_backend(tmp: &TempDir, base: String) -> Config { .expect("install SDK backend transport for referral mock"); let mut c = test_config(tmp); c.api_url = Some(base); - store_session_token(&c, "test-session-token"); + crate::hosted::test_support::store_session(&c, "test-session-token"); c } @@ -86,7 +72,7 @@ async fn get_stats_sends_trimmed_bearer() { let base = spawn_mock(app).await; let tmp = TempDir::new().unwrap(); let config = config_with_backend(&tmp, base); - store_session_token(&config, " tok "); + crate::hosted::test_support::store_session(&config, " tok "); let out = get_stats(&config).await.unwrap(); assert_eq!(out.value["auth"], json!("Bearer tok")); } diff --git a/crates/openhuman-tinyhumans/src/hosted/test_support.rs b/crates/openhuman-tinyhumans/src/hosted/test_support.rs index 9f2d28ef96f..552aa3f3dbf 100644 --- a/crates/openhuman-tinyhumans/src/hosted/test_support.rs +++ b/crates/openhuman-tinyhumans/src/hosted/test_support.rs @@ -6,6 +6,7 @@ use tempfile::TempDir; /// A config rooted in `tmp` whose backend is `api_url`. pub fn config(tmp: &TempDir, api_url: &str) -> Config { + init_keyring(); crate::install(crate::InstallOptions::default()).expect("install mock backend transport"); Config { workspace_dir: tmp.path().join("workspace"), @@ -18,6 +19,7 @@ pub fn config(tmp: &TempDir, api_url: &str) -> Config { /// Store `token` as the app-session credential for `config`. pub fn store_session(config: &Config, token: &str) { + init_keyring(); AuthService::from_config(config) .store_provider_token( APP_SESSION_PROVIDER, @@ -31,6 +33,7 @@ pub fn store_session(config: &Config, token: &str) { /// A signed-in config against `api_url` (session token `jwt.test`). pub fn signed_in(tmp: &TempDir, api_url: &str) -> Config { + init_keyring(); crate::install( crate::InstallOptions::default() .hosted_controllers(false) @@ -49,3 +52,21 @@ pub fn local_session(tmp: &TempDir) -> Config { store_session(&config, "desktop.test.local"); config } + +/// Initialize the encrypted keyring before core caches a missing OS keychain. +pub fn init_keyring() { + static KEYRING: std::sync::OnceLock = std::sync::OnceLock::new(); + KEYRING.get_or_init(|| { + use aes_gcm::aead::rand_core::RngCore; + let directory = tempfile::tempdir().expect("scratch keyring workspace"); + let mut bytes = [0_u8; 32]; + aes_gcm::aead::OsRng.fill_bytes(&mut bytes); + let key: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect(); + std::env::set_var("OPENHUMAN_WORKSPACE", directory.path()); + std::env::set_var("OPENHUMAN_KEYRING_BACKEND", "encrypted_file"); + std::env::remove_var("OPENHUMAN_KEYRING_MASTER_KEY_FILE"); + std::env::set_var("OPENHUMAN_KEYRING_MASTER_KEY", key); + openhuman_embed::process::init_master_key().expect("headless test master key"); + directory + }); +} diff --git a/crates/openhuman-tui/Cargo.toml b/crates/openhuman-tui/Cargo.toml index 0f2f9e81527..0ab0f9ed7f5 100644 --- a/crates/openhuman-tui/Cargo.toml +++ b/crates/openhuman-tui/Cargo.toml @@ -36,6 +36,7 @@ documents = ["openhuman-rpc/documents"] hosting = ["openhuman-rpc/hosting"] tinymemes = ["openhuman-rpc/tinymemes"] modules = ["openhuman-rpc/modules"] +security-module = ["openhuman-rpc/security-module"] voice = ["openhuman-rpc/voice"] web3 = ["openhuman-rpc/web3"] # Storage drivers for `[storage] url` / `OPENHUMAN_STORAGE_URL`. diff --git a/docs/README.ar.md b/docs/README.ar.md index adc65d0d85e..3e2a6a49233 100644 --- a/docs/README.ar.md +++ b/docs/README.ar.md @@ -143,7 +143,7 @@ irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/instal

مصمم للمطورين

-

البدء السريع مع Rust · دليل التضمين · أمثلة

+

البدء السريع مع Rust · دليل التضمين · أمثلة

استخدمه كمكتبة Rust: استدعِ وكيلاً كأي دالة أخرى، أو شغّل أسطولاً كاملاً من خادم صغير واحد.

@@ -342,7 +342,7 @@ println!("{}", reply.reply);
-بعد ذلك: [البدء السريع مع Rust](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)، و[دليل التضمين](https://tinyhumans.gitbook.io/openhuman/developing/embedding)، و[مستندات المطورين](https://tinyhumans.gitbook.io/openhuman/developing). +بعد ذلك: [البدء السريع مع Rust](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)، و[دليل التضمين](https://tinyhumans.gitbook.io/openhuman/developing/embed)، و[مستندات المطورين](https://tinyhumans.gitbook.io/openhuman/developing). --- diff --git a/docs/README.de.md b/docs/README.de.md index 57475cd8a5d..b8173040e83 100644 --- a/docs/README.de.md +++ b/docs/README.de.md @@ -117,7 +117,7 @@ Die meisten Agent-Harnesses starten pro Agent einen schweren Prozess und senden

Für Entwickler gebaut

-

Rust-Schnellstart · Einbettungsanleitung · Beispiele

+

Rust-Schnellstart · Einbettungsanleitung · Beispiele

Nutze es als Rust-Bibliothek: Rufe einen Agenten wie jede andere Funktion auf oder betreibe eine ganze Flotte auf einem kleinen Server.

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -Als Nächstes: der [Rust-Schnellstart](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), die [Einbettungsanleitung](https://tinyhumans.gitbook.io/openhuman/developing/embedding) und die [Entwicklerdokumentation](https://tinyhumans.gitbook.io/openhuman/developing). +Als Nächstes: der [Rust-Schnellstart](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), die [Einbettungsanleitung](https://tinyhumans.gitbook.io/openhuman/developing/embed) und die [Entwicklerdokumentation](https://tinyhumans.gitbook.io/openhuman/developing). --- diff --git a/docs/README.ja-JP.md b/docs/README.ja-JP.md index 71b8a49bced..2c86d21dde1 100644 --- a/docs/README.ja-JP.md +++ b/docs/README.ja-JP.md @@ -117,7 +117,7 @@ macOS と Linux のスクリプトが何を行うかを事前に確認するに

開発者のために

-

Rust クイックスタート · 組み込みガイド · サンプル

+

Rust クイックスタート · 組み込みガイド · サンプル

Rust ライブラリとして使えます。エージェントを普通の関数のように呼び出すことも、小さなサーバー1台でエージェント群をまるごと動かすこともできます。

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -次は、[Rust クイックスタート](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)、[組み込みガイド](https://tinyhumans.gitbook.io/openhuman/developing/embedding)、[開発者向けドキュメント](https://tinyhumans.gitbook.io/openhuman/developing)をご覧ください。 +次は、[Rust クイックスタート](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)、[組み込みガイド](https://tinyhumans.gitbook.io/openhuman/developing/embed)、[開発者向けドキュメント](https://tinyhumans.gitbook.io/openhuman/developing)をご覧ください。 --- diff --git a/docs/README.ko.md b/docs/README.ko.md index bbc290e6738..8d1ae352503 100644 --- a/docs/README.ko.md +++ b/docs/README.ko.md @@ -117,7 +117,7 @@ macOS와 Linux 스크립트가 무엇을 하는지 미리 보려면 명령 끝

개발자를 위해 만들었습니다

-

Rust 퀵스타트 · 임베딩 가이드 · 예제

+

Rust 퀵스타트 · 임베딩 가이드 · 예제

Rust 라이브러리로 사용하세요. 다른 함수처럼 에이전트를 호출하거나, 작은 서버 하나에서 수많은 에이전트를 실행할 수 있습니다.

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -다음으로는 [Rust 퀵스타트](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), [임베딩 가이드](https://tinyhumans.gitbook.io/openhuman/developing/embedding), [개발자 문서](https://tinyhumans.gitbook.io/openhuman/developing)를 보세요. +다음으로는 [Rust 퀵스타트](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), [임베딩 가이드](https://tinyhumans.gitbook.io/openhuman/developing/embed), [개발자 문서](https://tinyhumans.gitbook.io/openhuman/developing)를 보세요. --- diff --git a/docs/README.tr.md b/docs/README.tr.md index cd63024b1b3..095acf63395 100644 --- a/docs/README.tr.md +++ b/docs/README.tr.md @@ -117,7 +117,7 @@ macOS ve Linux betiğinin ne yapacağını önceden görmek için komutun sonuna

Geliştiriciler için tasarlandı

-

Rust hızlı başlangıç · Gömme rehberi · Örnekler

+

Rust hızlı başlangıç · Gömme rehberi · Örnekler

Bir Rust kütüphanesi olarak kullanın: bir ajanı herhangi bir işlev gibi çağırın ya da tüm bir filoyu tek bir küçük sunucudan çalıştırın.

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -Sırada: [Rust hızlı başlangıç](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), [gömme rehberi](https://tinyhumans.gitbook.io/openhuman/developing/embedding) ve [geliştirici dokümanları](https://tinyhumans.gitbook.io/openhuman/developing). +Sırada: [Rust hızlı başlangıç](https://tinyhumans.gitbook.io/openhuman/developing/quickstart), [gömme rehberi](https://tinyhumans.gitbook.io/openhuman/developing/embed) ve [geliştirici dokümanları](https://tinyhumans.gitbook.io/openhuman/developing). --- diff --git a/docs/README.ur-pk.md b/docs/README.ur-pk.md index b125cdf4ecc..6d1bb7a20e1 100644 --- a/docs/README.ur-pk.md +++ b/docs/README.ur-pk.md @@ -139,7 +139,7 @@ macOS اور Linux کی اسکرپٹ کیا کرے گی، یہ پہلے دیکھ

ڈیولپرز کے لیے بنایا گیا

-

Rust کوئیک اسٹارٹ · ایمبیڈنگ گائیڈ · مثالیں

+

Rust کوئیک اسٹارٹ · ایمبیڈنگ گائیڈ · مثالیں

اسے Rust لائبریری کے طور پر استعمال کریں: ایجنٹ کو کسی بھی عام فنکشن کی طرح کال کریں، یا ایک چھوٹے سرور سے پورا بیڑا چلائیں۔

@@ -333,7 +333,7 @@ println!("{}", reply.reply);
-اگلا قدم: [Rust کوئیک اسٹارٹ](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)، [ایمبیڈنگ گائیڈ](https://tinyhumans.gitbook.io/openhuman/developing/embedding) اور [ڈیولپر دستاویزات](https://tinyhumans.gitbook.io/openhuman/developing)۔ +اگلا قدم: [Rust کوئیک اسٹارٹ](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)، [ایمبیڈنگ گائیڈ](https://tinyhumans.gitbook.io/openhuman/developing/embed) اور [ڈیولپر دستاویزات](https://tinyhumans.gitbook.io/openhuman/developing)۔ --- diff --git a/docs/README.zh-CN.md b/docs/README.zh-CN.md index 66c0f693af6..db8c809d11c 100644 --- a/docs/README.zh-CN.md +++ b/docs/README.zh-CN.md @@ -117,7 +117,7 @@ irm https://raw.githubusercontent.com/tinyhumansai/openhuman/main/scripts/instal

为开发者而建

-

Rust 快速入门 · 嵌入指南 · 示例

+

Rust 快速入门 · 嵌入指南 · 示例

把它当作 Rust 库来用:像调用普通函数一样调用智能体,或者在一台小服务器上运行整个集群。

@@ -288,7 +288,7 @@ let reply = agent.run("Summarize what you can see in this directory.").await?; println!("{}", reply.reply); ``` -接下来看:[Rust 快速入门](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)、[嵌入指南](https://tinyhumans.gitbook.io/openhuman/developing/embedding)和[开发者文档](https://tinyhumans.gitbook.io/openhuman/developing)。 +接下来看:[Rust 快速入门](https://tinyhumans.gitbook.io/openhuman/developing/quickstart)、[嵌入指南](https://tinyhumans.gitbook.io/openhuman/developing/embed)和[开发者文档](https://tinyhumans.gitbook.io/openhuman/developing)。 --- diff --git a/docs/gitbooks/en/developing/embed/api-index.json b/docs/gitbooks/en/developing/embed/api-index.json index 76919ff831f..2fa0d37e1ad 100644 --- a/docs/gitbooks/en/developing/embed/api-index.json +++ b/docs/gitbooks/en/developing/embed/api-index.json @@ -76,6 +76,7 @@ "ModelDefaults", "OpenError", "PendingApproval", + "PermissionFuture", "PermissionLevel", "PickListenPortError", "ProfileError", @@ -126,6 +127,7 @@ "TrustedAccess", "TrustedAutomationSource", "Turn", + "TurnCancellation", "TurnContext", "TurnOutcome", "TurnRequest", @@ -136,6 +138,8 @@ "absolute", "agent_progress", "artifacts", + "budget", + "cancellation", "channels", "chat_surface", "complete", @@ -143,20 +147,25 @@ "cron", "embeddings", "events", + "fanout", "identity", "install_backend_transport", "installed_backend_transport", "memory", "modules", + "observe", "process", "profiles", "providers", + "repository", + "routing", "run_from_args", "schema_for_rpc_method", "seams", "session_store", "skill_registry", - "stream" + "stream", + "structured" ], "builder_setters": [ { @@ -435,7 +444,7 @@ "skills": true, "storage-file": false, "storage-mongodb": false, - "storage-sqlite": false, + "storage-sqlite": true, "tinymemes": true, "voice": false, "web3": false, diff --git a/docs/gitbooks/en/developing/embed/api-index.md b/docs/gitbooks/en/developing/embed/api-index.md index 3e386b0c6d6..9df96592b9c 100644 --- a/docs/gitbooks/en/developing/embed/api-index.md +++ b/docs/gitbooks/en/developing/embed/api-index.md @@ -79,6 +79,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`ModelDefaults`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`OpenError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PendingApproval`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`PermissionFuture`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PermissionLevel`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PickListenPortError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`ProfileError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -129,6 +130,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`TrustedAccess`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TrustedAutomationSource`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`Turn`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`TurnCancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnContext`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnOutcome`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnRequest`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -139,6 +141,8 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`absolute`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`agent_progress`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`artifacts`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`budget`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`cancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`channels`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`chat_surface`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`complete`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -146,20 +150,25 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`cron`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`embeddings`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`events`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`fanout`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`identity`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`install_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`installed_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`memory`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`modules`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`observe`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`process`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`profiles`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`providers`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`repository`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`routing`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`run_from_args`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`schema_for_rpc_method`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`seams`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`session_store`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`skill_registry`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`stream`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`structured`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) The compiled capability report describes this build: @@ -187,7 +196,7 @@ The compiled capability report describes this build: "skills": true, "storage-file": false, "storage-mongodb": false, - "storage-sqlite": false, + "storage-sqlite": true, "tinymemes": true, "voice": false, "web3": false, diff --git a/docs/gitbooks/en/developing/embed/capability-matrix.md b/docs/gitbooks/en/developing/embed/capability-matrix.md index aba183fbf7e..c6219821881 100644 --- a/docs/gitbooks/en/developing/embed/capability-matrix.md +++ b/docs/gitbooks/en/developing/embed/capability-matrix.md @@ -25,7 +25,7 @@ This matrix comes from the default-feature compiled capability-report example. R | `skills` | Yes | | `storage-file` | No | | `storage-mongodb` | No | -| `storage-sqlite` | No | +| `storage-sqlite` | Yes | | `tinymemes` | Yes | | `voice` | No | | `web3` | No | diff --git a/docs/gitbooks/en/developing/embed/cookbook.md b/docs/gitbooks/en/developing/embed/cookbook.md index 6b7caefc55b..3462bef8af5 100644 --- a/docs/gitbooks/en/developing/embed/cookbook.md +++ b/docs/gitbooks/en/developing/embed/cookbook.md @@ -76,6 +76,14 @@ Lean runtime without background services. Run: `cargo run -p openhuman-embed --example lean_headless` +## Linux agent fleet memory and latency + +Measure retained runtime-owned agents using loopback inference and two worker threads. + +[Source](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/linux_fleet.rs) · offline on Linux; use a fresh constrained cgroup for release measurements. + +Run: `cargo run -p openhuman-embed --example linux_fleet` + ## Connect an actual MCP protocol stub over loopback Connect an actual MCP protocol stub over loopback. diff --git a/docs/plans/security-remaining-7328.md b/docs/plans/security-remaining-7328.md new file mode 100644 index 00000000000..98b6108e1d0 --- /dev/null +++ b/docs/plans/security-remaining-7328.md @@ -0,0 +1,465 @@ +# Complete TinySecurity migration: remaining implementation + +Trackers: [OpenHuman #7328](https://github.com/tinyhumansai/openhuman/issues/7328), +[TinySecurity #1](https://github.com/tinyhumansai/tinysecurity/issues/1), +[TinyBox #27](https://github.com/tinyhumansai/tinybox/issues/27). +Binding owner design: `vendor/tinysecurity/docs/specs/security-module.md`, +`docs/specs/immutable-path-scopes.md`, `docs/plans/security-module.md`. + +## Delivery boundary and starting evidence + +Bootstrap plus immutable path scopes exist. TinySecurity #4 merged at +`f2b7ebd77c1f1109e08f6f38de87ad594841a0e3`. The module currently advertises +Evaluate, Check, PolicyInfo and four immutable path members. Its command engine +only allows argument-free diagnostics. `future.rs` contains reserved payloads, +not working approvals, scans, egress, audit or sandbox engines. The host's +`modules/security.rs::module_config` deliberately returns bootstrap defaults. +Do not treat any reserved type or passing bootstrap test as tracker completion. +Root is releasing that path milestone as v0.2.3 (run 38080694935). The next owner +implementer is already executing Task 3's immutable command registry. Finish and +review that task first; do not restart it or wait for the full migration plan. + +Deliver **one further cumulative TinySecurity PR**, against canonical upstream, +containing all owner work below. Continue the existing OpenHuman #7331 for host +work. Tasks are commit/review units, not separate TinySecurity PRs. Preserve all +checkpoint commits; never squash, reset, amend or bypass hooks. Root coordinates +release/pinning. Owner must merge and release before production host gitlinks, +registry versions or digests change. Copy every digest from release +`checksum.toml`, never from development builds. + +Work inside this existing superproject worktree; no nested worktrees. For another +owner repository, use the same superproject branch and that owner's upstream PR. +Do not place missing TinyBox/TinyMCP/TinyRuntime/TinyFlows capabilities into the +host or TinySecurity as workarounds. Their upstream changes must merge/release +before dependent production pins. One TinySecurity PR does not prohibit required +PRs in those other owners. + +## Invariants for every task + +- Host normal dependencies name only `tinysecurity-bus`; its normal dependencies + remain serde and thiserror, without runtime, transport, crypto or TinyTools. + Internals and dependencies are compiled into the native module. +- Preserve `approval.*`, `security.*`, `sandbox.*`, `encryption.*` RPC names and + existing payload compatibility. Add explicit migrations for stored/config data. +- Init/reinit carries secret credentials, endpoint and module service settings. + Invocation args carry authenticated context and scoped policy references, + never judge credentials, arbitrary tenant authority or model-provided tiers. +- Keep immutable PathPolicyId scopes. Expand to immutable full-policy scopes; + never serialize tenants through process-global reinit or let one agent change + another's policy. Policy activation increments the appropriate generation. +- Module absence, timeout, malformed reply or fault denies external effects. + A fault latches; no reload/retry of that module in the same process. Missing + caller configuration before a native call must not poison unrelated tenants. +- Disabled autonomy leaves discretionary classification/gates/allowlist/action + budget inert, preserving credential/system/traversal/NUL floor and access-tier, + origin, privacy and mandatory isolation boundaries. +- Write behavioral tests first, record the actual RED failure, implement, record + GREEN and owning-suite results. Keep host characterization until its replacement + tests exercise the same behavior through the released native module. +- Unit tests use explicit clocks/resolvers/storage seams. Unit files are sibling + `*_tests.rs`, start `use super::*;`, and are declared with `#[cfg(test)]` plus + `#[path = "…_tests.rs"] mod tests;`; never inline or legacy test filenames. +- No placeholders, empty crates, ignored failures or blanket lint allowances. + Advertise a method only when all its engine, storage and failure paths work. +- Long checks use `scripts/ci-cancel-aware.sh` from the host root. Never export + CARGO_TARGET_DIR or build under a temporary directory. Temp test data is fine. + +## Test protocol and contract interfaces + +Execution order: finish the in-progress command task, then remaining scoped +contracts/config, redaction, egress, callback/audit infrastructure, approvals, +judge, sandbox planning and crypto. Characterization/harness work precedes each +affected engine. Complete policy-widening human review after approval callbacks +exist; until then reject widening activation rather than install an approval +stub. Judge consumes completed redaction, audit and approvals. Sandbox/network +requirements remain hard denies until their engines work. Finish all owner gates +and the single owner release, then host migration tasks 12–16. This staged order +prevents intermediate methods from advertising incomplete authorization. + +For each owner task, first run its named filter using +`cargo test --manifest-path vendor/tinysecurity/Cargo.toml -p `; +save RED/GREEN output in the controller ledger. Then run the crate suite. +For host tests use `cargo test -p openhuman-cli --test ` or +`cargo test -p openhuman ` through the cancellation-aware wrapper. +New root tests require explicit `[[test]]` entries in +`crates/openhuman-cli/Cargo.toml`. In-process backend tests call +`tests/support/tinyhumans_boot.rs::boot()` before use; network services are mocked. + +Extend bus types in focused `policy.rs`, `approval.rs`, `redact.rs`, `egress.rs`, +`sandbox.rs`, `audit.rs`, `crypto.rs`, `callbacks.rs`; re-export from `lib.rs`. +Replace definitions in `future.rs` with compatibility re-exports rather than +duplicate types. Preserve existing method constants and fixtures. Define a +contract-version change and explicit old/new compatibility tests when adding +required fields or enum variants to strict serde payloads. + +Shared interfaces to implement: + +- `RegisterPolicy(RegisteredPolicy { path_policy_id, settings, tool_rules, + command_policy_id, subject_scope }) -> PolicyId`; immutable typed ID that + composes existing immutable path/command registries rather than duplicating + them. Scope includes authenticated + user/workspace/agent ceiling, not just agent name. Scoped Evaluate/Check bind + PolicyId, verified CallerContext and canonical call fingerprint. Existing + bootstrap calls retain safe behavior without a scope; they never gain effects. +- `PolicySettings`: enabled, allowed commands, rate limit, privacy, approvals, + auto-approve origin rules, sandbox defaults and audit requirements. Path settings + remain in PathPolicy. `PolicyInfo` returns all effective nonsensitive settings, + current generation, implemented members and callback availability. +- `Decision` keeps generation/verdict/cacheable. Extend typed denial reasons for + rate, privacy, URL, isolation, expired grant and required persistence failures. + Unknown effects deny. Mutable approvals/rates/DNS results are never cacheable. +- Approval records bind owner, origin, thread/flow, policy scope/generation, + call fingerprint, expiry, lifecycle, decision attribution and execution outcome. + ApprovalStore supports scoped load and atomic compare-and-set transitions, + plus durable flow/tool grants. Callback commits are explicit acknowledgements. +- Callback clients use SDK bus calls with bounded timeouts and typed errors. + Never hold a module/host state mutex while awaiting host prompt/store callbacks. + Reentrant Decide during a parked Evaluate must be supported without deadlock. + +## Task 1 — Characterization inventory and native harness + +**Host files:** `tests/security_policy_characterization.rs`, +`tests/security_approval_characterization.rs`, +`tests/security_redaction_characterization.rs`, +`tests/security_sandbox_characterization.rs`, +`tests/security_crypto_characterization.rs`, CLI manifest test tables; +`crates/openhuman-core/src/modules/security_native_tests.rs`. +**Owner files:** `crates/tinysecurity-module/tests/native_contract.rs`, +`crates/tinysecurity-bus/tests/fixtures/`, `docs/performance.md`. + +1. Pin existing approval RPC/event shapes, TTLs (600s/180s), origin/flow/tool trust, + disabled/enabled policy, command syntax, sandbox precedence and encrypted data. +2. Build one corpus from all host redactors, preserving missed-secret cases as + explicit desired regressions rather than blessing their current omissions. +3. Native harness loads a real cdylib, serves typed callbacks, detects malformed + args and exposes deterministic clock/resolver/stub-judge seams for later tasks. +4. Capture existing in-process and native Evaluate/Check latency p50/p99 and calls + per turn; record runner/hardware/workload. Set a measured budget in + `docs/performance.md` before host caller migration, then enforce it in CI. +**Exit:** characterization passes, native harness proves actual dispatch; desired +missing behaviors are RED, not removed or marked successful. + +## Task 2 — Full scoped configuration and activation + +**Owner files:** bus `policy.rs`, `names.rs`, `callbacks.rs`, policy +`src/policy_registry.rs`, `src/policy_registry_tests.rs`, module `adapter.rs`. +**Host after release:** `config/schema/security.rs`, schema `mod.rs`, +`config/migrations/security_policy.rs`, migration `mod.rs`, +`modules/security_config.rs`, `security/live_policy.rs`. + +1. RED: concurrently register two agents/tenants with opposite command/privacy + rules; scoped evaluations remain isolated during reload and invalid reinit. +2. Implement validated immutable policy registration and complete PolicyInfo. + Config activation is atomic; failure leaves prior policy active. Review widening + changes (roots/hosts/tools/classes/auto origins/judge) through human approval + under old policy before publishing the new generation; cannot self-approve. +3. Choose and implement one `[security]` table. Migrate legacy autonomy/sandbox/ + privacy fields with deterministic precedence and preserve default policy off. + Remove unreachable DaemonConfig.security/SecurityConfig duplication. +4. Remove unused `max_cost_per_day_cents` with a migration notice and fixture; + do not advertise an unenforced cost budget. Keep actual judge budget separately. +5. Host translator derives authenticated scope and all fields once. No operator + config/env fallback for SaaS. Secret settings never enter PolicyInfo or logs. +**Exit:** round-trip old config fixtures and isolated new effective policies pass. + +## Task 3 — Command grammar, tool rules and action accounting + +**In progress:** root has dispatched this task; resume its result/review instead +of dispatching a second implementer. The agreed contract is immutable +`CommandPolicy { enabled, autonomy, allowed_commands, max_actions_per_hour, +require_approval_for_medium_risk, block_high_risk_commands, action_dir, home_dir, +execution_mode }`, opaque `CommandPolicyId`, RegisterCommandPolicy, +ClassifyCommand and CheckCommand. Mode is `HarnessGated | Allowlisted`, selected +only at trusted registration. Pure classification returns typed class/risk/gate/ +denial and reserves nothing; Check atomically reserves allowed hourly actions. +Registry survives reinit, clocks are injected and stateful checks are uncached. +HarnessGated preserves legacy shell check_gated_command behavior without imposing +Allowlisted's allowlist/risk gate; validate_command_execution uses Allowlisted. +Later approval/middleware gates derive command-class decisions from verified +context. Keep these distinctions in characterization and native tests. + +**Owner files:** policy `src/command.rs`, `src/command/{classify,scan,env_guard}.rs`, +`src/rules.rs`, `src/rate.rs`, `src/engine.rs`, sibling tests; bus policy types. +**Source parity:** host `security/policy/{command_checks,enforcement,types}.rs`, +`tools/rules/`; TinyBox shell classifier/scanner/environment rules. + +1. RED: POSIX compounds/substitution/redirection, quoted heredoc data, expanded + heredocs; PowerShell/cmd escaping, paths and PATHEXT executable resolution. +2. Port classification and scanning to TinySecurity. Known reads remain reads, + unknown commands become writes when enabled. Never trust declared class. + Floor scanning recognizes protected literals through supported syntax. +3. Reuse the existing vendored TinyTools ToolRules/ApprovalDirective vocabulary + internally via the single TinyAgents-owned copy; translate serde bus records + mechanically. Tool visibility/access ceiling and explicit denies dominate. +4. Deterministic reservation/commit/release accounting enforces hourly actions + without duplicate charges on parked/resumed calls. Disabled policy does not + reserve. Reinit/cache cannot replenish or bypass active reservations. +5. Preserve merged path registry tests, adding full-policy binding and real native + Windows/APFS/Linux cases; no second path normalizer. +**Exit:** complete command/rule/rate parity through native Evaluate/Check. + +## Task 4 — One redactor and integrated scans + +**Owner files:** new `crates/tinysecurity-redact/{Cargo.toml,src/lib.rs}`, internal +`src/{patterns,structured,prompt}.rs` and sibling tests; bus `redact.rs`; +module dispatch; workspace members and internal umbrella wiring. + +1. RED shared corpus: secrets, PII/identifiers, nested args, key names, escaped JSON, + URL userinfo/query, Unix/macOS/Windows home paths, malformed/bounded inputs. +2. Implement one registry with explicit modes. Add idempotence/no-secret-survives + properties and bounded-work fuzz cases; preserve useful nonsensitive errors. +3. Implement ScanPrompt/ScanToolDefinition verdicts and stable rule IDs, route + blocked/suspicious results into policy/approval. Scanner verdict is context, + never authorization. Document TinyMCP protocol sanitation and TinySkills + package scanning as owner inputs; avoid reproducing those implementations. +4. Redact before storage, broadcasts, audit and judge. Sensitive log paths on + module failure suppress content rather than return original text. Only a + documented fixed boot diagnostic floor may exist before module readiness. +**Exit:** native Redact/scans and every shared-corpus/property test pass. + +## Task 5 — URL guard, privacy and actual rebinding protection + +**Owner files:** new `crates/tinysecurity-egress/{Cargo.toml,src/lib.rs}`, internal +`src/{url_guard,resolver,privacy}.rs`, sibling tests; bus `egress.rs`. +**Host after release:** `tools/impl/network/host.rs`, `modules/browser.rs`, +`security/egress/`, `web_chat/egress_surface.rs`, `inference/provider/factory.rs`, +Composio loopback gates and `util/url.rs` callers. + +1. RED literals: metadata/link-local, CGNAT, private/mapped IPv6, unusual numeric + IPv4 forms, zones, userinfo, unsupported schemes and mixed-address DNS answers. +2. Resolve in module, validate every candidate, return hostname plus exact approved + IPs and bounded validity. Any forbidden candidate denies. Redirects are checked + independently. Destination allowlist is scoped policy, not caller authority. +3. Host HTTP transport disables unchecked automatic redirects and connects only + to approved IPs while preserving Host/SNI/TLS hostname. Test a resolver that + changes after validation and a real local transport proving no second lookup. +4. Browser/TinyComputer or proxy routes require a checked egress proxy capable of + per-hop pinning; if unsupported, deny protected network operation. URL string + validation alone must never be reported as rebinding protection. +5. LocalOnly denies nonlocal egress; Standard enforces declared destination/data + policy; Sensitive denies identifying/credential-bearing raw egress unless an + explicit approved transformation removes it and descriptor is rechecked. + All inference/embedding/memory/integration/browser paths supply descriptors. +**Exit:** native URL/privacy tests and real pinned-transport tests pass. + +## Task 6 — Audit engine and callback infrastructure + +**Owner files:** new `crates/tinysecurity-audit/{Cargo.toml,src/lib.rs}`, internal +`src/{event,sink,rotation}.rs`, tests; bus `audit.rs`, `callbacks.rs`; module +`src/callbacks.rs`, callback and adapter tests. +**Host after release:** `modules/security_host.rs`, `modules/mod.rs`, +`security/approval/store*.rs`, existing host storage driver wiring. + +1. RED: sink/store unavailable, timeout, wrong owner, malformed acknowledgement, + callback reentrancy, duplicate events and rotation during Windows file locking. +2. Implement bounded typed callback clients. Native callback harness verifies + bus interface identities, caller ownership and commit acknowledgements. +3. One ordered audit stream covers policy, approval, judge, shell/execution and + sandbox decisions. Redact event summaries; use content-free applied rule IDs. +4. Required audit failure denies before execution. Optional failure reports + sanitized degraded health, never falsely committed. JSONL rotation uses 0600 + on Unix and restricted Windows ACLs. Host callback sink is configurable. +**Exit:** persisted native audit and callback failure/security tests pass. + +## Task 7 — Durable approvals, grants and expiry + +**Owner files:** new `crates/tinysecurity-approval/{Cargo.toml,src/lib.rs}`, internal +`src/{state,store,grants,reply}.rs`, tests; bus `approval.rs`; module adapters. + +1. RED pending→decided→executed, expiry at exact boundary, restart reload, + duplicate/replayed Decide, concurrent decision, wrong tenant/context/fingerprint, + changed policy, callback failure and terminal outcome acknowledgement. +2. Persist pending before RequireApproval. Store transitions are CAS/idempotent. + Module reloads scoped durable records/grants; expired state never authorizes. + TTL defaults 600s, copilot/sub-agent 180s; test injected-clock rollback safely. +3. Implement allow once/tool/flow and deny; tool grants persist/reload without + silently granting different arguments/classes, and flow grants bind reviewed + fingerprints. Policy widening invalidates/reviews affected grants. +4. ParseReply only parses intent; Decide requires authenticated human authority. + Cron reads only; external effects deny. SaaS uses authenticated per-user prompt + callback, denies if absent; remove unconditional SaaS approval bypass. +5. Host owns parked futures/cancellation and ApprovalRequested/ApprovalDecided + events. Module owns state/TTL/log. Callbacks do not create recursive lock waits. +**Exit:** restart-surviving real native approval round trip, no unsafe grant replay. + +## Task 8 — Rules and optional Jev judge + +**Owner files:** new `crates/tinysecurity-auto/{Cargo.toml,src/lib.rs}`, internal +`src/{rules,judge,budget}.rs`, tests; init JudgeConfig and approval wiring. + +1. RED off-default/per-origin opt-in, allowlist/flow grants, auto_approve_all audit, + judge allow/low-confidence/timeout/malformed/error/budget exhaustion and Deny. +2. Use tinyinference-decisions Jev API internally. Init supplies endpoint/credential; + host resolves via resolve_backend_credential. No secret invocation fields. +3. Send redacted intent/reversibility/exfiltration questions; configurable class/ + origin thresholds and deterministic budget. Only RequireApproval may become + Allow; hard denies, floor, privacy and isolation remain unchanged. +4. All errors/uncertainty fall back to human, unavailable human channel denies. + Audit sanitized scores/thresholds/question IDs with auto:jev. Cache cannot + issue reusable permission or bypass mutable checks. +**Exit:** native tests against local stub judge on all three OSes. + +## Task 9 — Sandbox planning with complete capability policy + +**Owner files:** new `crates/tinysecurity-sandbox/{Cargo.toml,src/lib.rs}`, internal +`src/{resolve,grants,capabilities}.rs`, tests; bus `sandbox.rs`, module Plan. +**Owner prerequisites:** TinyBox #27 real jail/namespace/microvm/Docker contracts. + +1. RED SaaS + env off, source tiers, agent mode/config precedence, unsupported + capabilities, Noop for untrusted code, credential grants, host networking. +2. Extend request with verified SaaS/env/mode facts and policy scope; resolve + backend, resources, network and grants deterministically. Credentials never + enter grants. Actual executor suitability still checked immediately at spawn. +3. SaaS always isolates; env off never overrides. Untrusted MCP/skills/downloads + select microvm where available, otherwise an explicitly suitable real backend; + unavailable suitable isolation denies. Host-network needs approval. +4. Implement Firejail/Bubblewrap config via supported namespace mapping or migrate + to namespace and remove obsolete firejail_args/empty resource config. Every + surviving resource field must map to an enforced TinyBox limit. +**Exit:** plan matrix and native Plan tests pass, no optimistic unsupported plan. + +## Task 10 — Unified crypto, keyring and pairing + +**Owner files:** new `crates/tinysecurity-crypto/{Cargo.toml,src/lib.rs}`, internal +`src/{password,keyring,device,pairing}.rs`, platform backend submodules, tests; +bus `crypto.rs` and constants; module dispatch and KeyringConsent callbacks. +**Host sources:** `security/encryption/core.rs`, `security/keyring/{crypto, +encrypted_store,encrypted_file_backend,backend}.rs`, `security/devices/crypto.rs`, +`security/pairing.rs` and existing encrypted fixture files. + +1. RED decrypt existing Argon2id/AES-GCM and encrypted-file fixtures; malformed + ciphertext/version/tag, wrong password, X25519/HKDF tunnel compatibility, + pairing TTL/replay/rate limits, keychain consent deny/unavailable and restart. +2. Port implementation once; native backend owns OS keyring/encrypted fallback. + Credential ownership/auth, device sockets, pairing UI and consent UX stay host. + Opaque key/session handles bind authenticated owner; never return key material + through info/log/audit. Define explicit binary payload size limits. +3. Validate native Secret Service/file, macOS Keychain and Windows Credential + Manager round trips; remove test fixtures from OS keyrings after the test. + Document genuinely unsupported runner consent setup, do not simulate OS tests. +**Exit:** existing data readable, bus crypto complete, real platform backends tested. + +## Task 11 — Owner quality, review, single PR and release + +**Files:** owner workflows `ci.yml`, `release.yml`, native test matrix, fuzz targets, +`deny.toml`, docs/performance, MODULE/specs/ADRs/ROADMAP; no manual version bump. + +1. All new members must be native-dispatched, listed in PolicyInfo and covered by + malformed/timeout/fault tests. Fuzz commands/paths/redactor/URL; per-file line + coverage ≥90%; cargo deny, MSRV and warning-free rustdoc. +2. Full fmt/clippy/build/test all-features and feature-off checks. Benchmark + Evaluate/Check p50/p99 against committed budget; batching/cache fixes remain + bus-based and cannot cache rates/grants/DNS authorization. +3. Real Linux/macOS/Windows CI proves loading/digests, approvals/restart, policy, + URL pinning, audit permissions, sandbox plans and keyring. Release matrix + produces every supported artifact (11 registry platform keys) and checksum. +4. Broad spec/code review, address feedback, merge ONE remaining TinySecurity PR, + dispatch semantic release workflow. Verify published artifacts by real loader + allow/deny/callback flow. Only now can production host pins change. + +## Task 12 — Host pins and config/command/redaction migration + +**Files:** registry security record, `scripts/ci/check-module-pins.mjs`, +`modules/security{,_config,_host}.rs`, `security/live_policy.rs`, schema/migrations, +`security/policy/`, network/fs/shell callers and redaction consumers. + +1. Root pins released owner gitlink/version/checksums together and validates + monotonic/pin/feature/bus-only checks; no local artifact digest admission. +2. Implement complete scoped translator and client; bind immutable policy IDs + to host verified context. Tenant settings are never process-global init policy. +3. Route tools/in-tool checks to coarse Evaluate/batched Check and registered + path scopes. Cache only explicitly cacheable static decisions by generation, + complete fingerprint and verified caller scope; invalidate on activation. +4. Replace host redactors at util/redact, approval/redact, security/core/scrub/pii, + core/log_redaction, registry/denials, Composio redact, credential_scrub and URL + helpers. Port characterization tests, delete duplicated implementations only + after native parity; suppress sensitive logging while module unavailable. +5. Install pinned URL transport/egress/scans at every Task 5 consumer and + agent/bus.rs, session_host/runtime/run_loop.rs, mcp/registry. Verify secret + suppression in Sentry/logs/approval/audit and real redirect/rebinding denial. + +## Task 13 — Host approval adapter, middleware and frontend + +**Files:** `security/approval/{gate,gate_intercept,store,schemas,rpc}.rs` and +related fragments; `agent/tinyagents/middleware/tinysecurity.rs`, middleware.rs, +`tools/agent_policy/`, `agent/tool_policy.rs`, middleware/{approval,tool_policy}.rs; +`app/src/components/settings/panels/ApprovalHistoryPanel.tsx`, locale resources, +`app/test/e2e/specs/security-approval.spec.ts`. + +1. Keep approval RPC/event compatibility; store/prompt facades serve scoped + callbacks. Module owns transitions/expiry/flow trust/log, host parks/resumes. + Preserve cancellation and exactly-one execution outcome; no auto_approve bypass. +2. Register one TinySecurityMiddleware before effect-capable middleware. Map + module decisions to tinyagents PolicyDecision, bind recorded tool/rule inputs. + Delete duplicate agent_policy/ToolPolicyPosture wrappers and three disallowed + checks. Keep declarative ToolRules and one scope/dispatch adapter. +3. UI displays auto:jev verdicts/scores and pending/expiry state with shadcn + primitives, useT and real translations. Cover prompt/decide/history/badge via + mocked desktop E2E helpers and existing approval component/API suites. +4. Run i18n:check, i18n:english:check and i18n coverage; no user text/IDs in analytics. + +## Task 14 — Every untrusted process uses approved TinyBox execution + +**Host files:** sandbox/{ops,types,grants,docker}.rs, +`agent/host_runtime.rs`, `cron/scheduler/shell_job.rs`, `hooks/host.rs`, +`runtime/python_server/{server,kompress}.rs`, `runtime/pool/`, +`tools/impl/system/install_tool.rs`, MCP/runtime/flow host adapters. +**Owner seams:** TinyMCP transport/stdio/mod.rs; TinyRuntime pool/worker.rs and +tinyruntime-pyserver/src/server.rs; TinyFlows caps/host/script/runner.rs; +TinyComputer browser process startup/egress contracts. + +1. Replace resolve_* with module Plan→TinyBox BoxSpec/Placement conversion. + Real `SandboxCapabilities::is_suitable_for_untrusted_code()` gates spawn; + unsupported or Inactive is denial for untrusted tiers. Replace handwritten + DockerRuntime docker run with tinybox_docker::OneShot. Keep execution host-side. +2. Maintain a committed spawner inventory from Command::new/process spawn search. + Each cron/MCP stdio/runtime worker+pyserver+compression/command hook/flow script/ + installer/downloaded skill/browser process row names source trust, plan, + executor and test. Launch probes must not become execution bypasses. +3. Where owner lacks process-launch injection, add owner seam and upstream tests, + merge/release/pin first. No direct stdio/worker spawn bypass in composed product. +4. Real tests attempt protected file access/network from each untrusted family; + confirm denial inside the actual OS jail/namespace/AppContainer/Seatbelt. + KVM job tests microvm; Docker integration tests verify grants and network. + SaaS+env off ordering/boot_guard stays pinned. Explicit unsupported platforms + deny instead of silently skipping product behavior. + +## Task 15 — Host audit, scan/consequence gates and crypto facades + +**Files:** shell audit consumers, `mcp/audit/`, security encryption/keyring/device/ +pairing adapters, web3/seams.rs, x402/seams.rs, TinyComputer/TinySkills adapters. + +1. Route policy/approval/judge/shell/sandbox changes into one module audit. MCP + protocol telemetry remains TinyMCP-owned; policy events forward to same audit + stream. Record this ownership in ADR, remove duplicate policy logs. +2. Feed TinyComputer consequence/payment gates and TinySkills scan verdicts into + policy/approval before execution. Add upstream callback contracts where needed; + no documented bypass can substitute for a missing enforcement seam. +3. Replace crypto/keyring/pairing implementations with bus facades; preserve + encryption.* and web3/x402 consumers. Port all fixture/migration tests before + deleting old code; device networking/credentials/consent surface remain host. +4. Remove obsolete aliases, unused redaction/PII helpers, unused_import allowances, + config fields and old tests only together with passing replacement coverage. + +## Task 16 — Final matrix, documentation and completion evidence + +**Files:** `.github/workflows/{security-native,ci-full,test-reusable}.yml`, +`scripts/ci/security-native-fixture.sh`, dependency/pin checks; AGENTS ownership +(CLAUDE symlink), `gitbooks/developing/loadable-modules.md`, +`gitbooks/features/{approval-gate,privacy-and-security}.md`, security/approval/ +sandbox README files, `platform/about_app/`; generated docs via scripts only. + +1. Native host matrix loads RELEASED module, tests tampered digest, fault/timeout + latched deny with zero retry, two-tenant isolation, approval RPC/event/restart, + judge stub, OS command/path semantics, pinned URL transport, audit permissions, + crypto/keyring and actual sandbox effects. No mocked native module or isolation. +2. Run pnpm rust:layout; feature forwarding, module pin/monotonic and bus-only + checks; product-feature enabled/disabled builds; targeted Rust/frontend/E2E + suites, changed-line coverage ≥80%. Owner per-file requirement remains ≥90%. +3. Write GitBook/docs accurately, including real privacy/SaaS/judge/fault behavior, + approved release count/builds, callback ownership and isolation limitations. + Run pnpm docs:generate and pnpm docs:check. Review entire branch, then independent + completion verification of all claimed checks. Keep PR #7331 ready for review. +4. Trackers close only when all engines, released artifacts, migrated consumers, + every spawner row, deleted duplicates, cross-OS tests and docs are evidenced. + Any absent backend/test/release remains an explicit open task, never DONE. diff --git a/gitbooks/developing/embed/api-index.json b/gitbooks/developing/embed/api-index.json index 76919ff831f..2fa0d37e1ad 100644 --- a/gitbooks/developing/embed/api-index.json +++ b/gitbooks/developing/embed/api-index.json @@ -76,6 +76,7 @@ "ModelDefaults", "OpenError", "PendingApproval", + "PermissionFuture", "PermissionLevel", "PickListenPortError", "ProfileError", @@ -126,6 +127,7 @@ "TrustedAccess", "TrustedAutomationSource", "Turn", + "TurnCancellation", "TurnContext", "TurnOutcome", "TurnRequest", @@ -136,6 +138,8 @@ "absolute", "agent_progress", "artifacts", + "budget", + "cancellation", "channels", "chat_surface", "complete", @@ -143,20 +147,25 @@ "cron", "embeddings", "events", + "fanout", "identity", "install_backend_transport", "installed_backend_transport", "memory", "modules", + "observe", "process", "profiles", "providers", + "repository", + "routing", "run_from_args", "schema_for_rpc_method", "seams", "session_store", "skill_registry", - "stream" + "stream", + "structured" ], "builder_setters": [ { @@ -435,7 +444,7 @@ "skills": true, "storage-file": false, "storage-mongodb": false, - "storage-sqlite": false, + "storage-sqlite": true, "tinymemes": true, "voice": false, "web3": false, diff --git a/gitbooks/developing/embed/api-index.md b/gitbooks/developing/embed/api-index.md index 3e386b0c6d6..9df96592b9c 100644 --- a/gitbooks/developing/embed/api-index.md +++ b/gitbooks/developing/embed/api-index.md @@ -79,6 +79,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`ModelDefaults`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`OpenError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PendingApproval`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`PermissionFuture`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PermissionLevel`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PickListenPortError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`ProfileError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -129,6 +130,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`TrustedAccess`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TrustedAutomationSource`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`Turn`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`TurnCancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnContext`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnOutcome`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnRequest`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -139,6 +141,8 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`absolute`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`agent_progress`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`artifacts`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`budget`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`cancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`channels`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`chat_surface`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`complete`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -146,20 +150,25 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`cron`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`embeddings`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`events`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`fanout`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`identity`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`install_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`installed_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`memory`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`modules`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`observe`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`process`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`profiles`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`providers`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`repository`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`routing`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`run_from_args`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`schema_for_rpc_method`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`seams`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`session_store`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`skill_registry`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`stream`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`structured`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) The compiled capability report describes this build: @@ -187,7 +196,7 @@ The compiled capability report describes this build: "skills": true, "storage-file": false, "storage-mongodb": false, - "storage-sqlite": false, + "storage-sqlite": true, "tinymemes": true, "voice": false, "web3": false, diff --git a/gitbooks/developing/embed/capability-matrix.md b/gitbooks/developing/embed/capability-matrix.md index aba183fbf7e..c6219821881 100644 --- a/gitbooks/developing/embed/capability-matrix.md +++ b/gitbooks/developing/embed/capability-matrix.md @@ -25,7 +25,7 @@ This matrix comes from the default-feature compiled capability-report example. R | `skills` | Yes | | `storage-file` | No | | `storage-mongodb` | No | -| `storage-sqlite` | No | +| `storage-sqlite` | Yes | | `tinymemes` | Yes | | `voice` | No | | `web3` | No | diff --git a/gitbooks/developing/embed/cookbook.md b/gitbooks/developing/embed/cookbook.md index 6b7caefc55b..3462bef8af5 100644 --- a/gitbooks/developing/embed/cookbook.md +++ b/gitbooks/developing/embed/cookbook.md @@ -76,6 +76,14 @@ Lean runtime without background services. Run: `cargo run -p openhuman-embed --example lean_headless` +## Linux agent fleet memory and latency + +Measure retained runtime-owned agents using loopback inference and two worker threads. + +[Source](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/linux_fleet.rs) · offline on Linux; use a fresh constrained cgroup for release measurements. + +Run: `cargo run -p openhuman-embed --example linux_fleet` + ## Connect an actual MCP protocol stub over loopback Connect an actual MCP protocol stub over loopback. diff --git a/gitbooks/developing/loadable-modules.md b/gitbooks/developing/loadable-modules.md index 0bbe97332cd..2efa748bee3 100644 --- a/gitbooks/developing/loadable-modules.md +++ b/gitbooks/developing/loadable-modules.md @@ -13,7 +13,10 @@ The trade is explicit. A module shares the core's address space and crash domain ## The registry -`crates/openhuman-core/src/modules/registry/` compiles in fourteen records. Only this registry may select an artifact. Nothing reads a module name from config and goes looking for it. +`crates/openhuman-core/src/modules/registry/` compiles in fourteen released +records, plus the TinySecurity admission record when `security-module` is +enabled. Only this registry may select an artifact. Nothing reads a module name +from config and goes looking for it. | Module | Provides | | --- | --- | @@ -34,6 +37,21 @@ Each record carries a version, the release it came from, and one `PlatformAsset` Every asset carries a SHA-256 copied verbatim from the published release's own checksum file. Do not compute a replacement pin from a local build. The digest must describe the artifact the release workflow signed, not the one on your machine. +### TinySecurity migration + +`vendor/tinysecurity` owns the native security engine. The `security-module` +feature links its transport-free `tinysecurity-bus` contract and forwards through +the host library chain. Asynchronous filesystem checks use immutable scopes +containing host-authorized roots and internal-state reservations. The client +requires artifact attestation and fails closed if loading or validation fails. + +The production registry pins TinySecurity v0.2.2 and its 11 supported host +archives using digests copied from the published checksum manifest. Native CI +loads these released archives through digest admission. Explicit local fixtures +remain available for module development and never replace production pins. +Shell policy, approvals, redaction, and crypto still run through their existing +host implementations. + ## Resolution order Each step avoids the cost of the next: diff --git a/llms-full.txt b/llms-full.txt index 1658cc4d625..760b0e639c2 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -2441,6 +2441,85 @@ The [cookbook](https://github.com/tinyhumansai/openhuman/blob/main/gitbooks/deve Hosts supply transport, credentials and application resources. Runtime settings establish shared defaults; agents narrow provider, access, prompt and tool behavior. Use ProfileRuntime when users require separate credentials and workspaces. +## Cancelling one turn + +Acquire `Turn::cancellation_handle()` before sending a turn. The handle is +cloneable and `cancel().await` waits for the turn to stop and for tracked +commands to be reaped. The agent remains available for later turns: + + + +Cancellation is scoped to this turn, including while waiting for inference. +Turns with a cancellation handle use owned interpreter subprocesses rather +than the Node/Python pool, which has no acknowledged per-job abort API. This +trades warm-worker reuse for awaited cleanup; ordinary turns retain pooling. +The `meter` callback fires once on cancellation or a dropped send future, +with `None` when dispatch has not supplied usage yet. + +Before send, cancellation prevents dispatch; after completion it is a no-op. +On Unix, the built-in shell, Node, Python and npm commands kill their process +group, including descendants. Other platforms stop the direct command. Host +tools that spawn independent tasks or processes must provide their own cleanup; +MCP server lifecycles remain owned by the agent. Keep polling `send()` while +awaiting cancellation, for example in a spawned task. + +## Scoped worker hooks + +Hooks can be supplied at three levels: `RuntimeBuilder::tool_hook` / +`post_turn_hook` for all agents, `AgentSpec::tool_hook` / `post_turn_hook` +for one agent, and `Turn::tool_hook` / `post_turn_hook` for one dispatch. +Tool callbacks run in that order. Named agent updates replace only that agent’s callback; per-turn callbacks are additional. +`ToolHookContext` carries the agent/session identity when known, and `cwd` +follows the execution workspace descriptor (including `Turn::cwd`), falling +back to the embedding context's configured action root. +The agent and turn hooks are never installed in the global registry, so +concurrent workers and later turns do not pick up one another's callbacks. +Post-turn callbacks run asynchronously with an owned session snapshot. +Independently spawned tasks that build sessions must explicitly inherit +`openhuman_core::agent::hooks::HookScope` to carry scoped hooks. + +Gateway attribution headers can be attached to `Route::header(name, value)` +and used with `Turn::route`, or with `Provider::routed(route)` on an agent. +They follow only that route's endpoint and are never saved to configuration, +sent to background providers, or included as values in `Route`'s `Debug`. + +### Inline permission and usage policy + +`AgentSpec::can_use_tool` and `Turn::can_use_tool` await a host callback before +executing each tool. The callback can wait for an approval UI and return +`ToolHookDecision::Proceed`, `Deny`, or `ProceedWith`. It owns that wait; +returning `Ask` denies execution. These callbacks add to existing tool policies, +and a turn callback cannot override an agent denial. + +`AgentSpec::stop_hook` and `Turn::stop_hook` receive cumulative usage after each +completed model call. Return `StopDecision::Continue` to observe usage, or +`Stop` to prevent subsequent calls. Completed tool rounds may still execute; +this is an after-call budget boundary, so hosts must refuse an already exhausted +budget before sending a turn. Provider-reported charges remain authoritative, +including known zero; missing charges remain unknown unless pricing is known. +A policy stop uses a deterministic partial summary rather than spending on +final-answer repair calls. + +### Per-turn tools and subprocess environment + +`Turn::tools` replaces this turn's host tool belt, including attached sources. +An empty belt revokes host tools; the next turn returns to the agent's belt. +Builtin tools still follow the agent definition. This supplies dynamic tools for +in-process hosts; statically declared MCP servers retain their creation-time +configuration. + +`Turn::tool_env` supplies the base environment of owned builtin subprocesses. +Variables absent from it are not inherited from the daemon. The builtin command +builders retain their own security/runtime additions, including Git restrictions, +managed interpreter paths and scratch directories. Scoped turns bypass Node and +Python pools, which cannot acknowledge per-job cancellation or swap a job's +process environment. A host tool that spawns a separate Tokio task must explicitly +carry the command environment and cleanup scopes into that task. + +Standalone exact source pins and generated Cargo patches: [consumer setup](CONSUMERS.md). +Ordered fallbacks and required exploration: [routing](ROUTING.md). +Host telemetry and the existing exporter: [observers](OBSERVERS.md). + # Embedding OpenHuman @@ -2529,6 +2608,14 @@ Lean runtime without background services. Run: `cargo run -p openhuman-embed --example lean_headless` +## Linux agent fleet memory and latency + +Measure retained runtime-owned agents using loopback inference and two worker threads. + +[Source](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/linux_fleet.rs) · offline on Linux; use a fresh constrained cgroup for release measurements. + +Run: `cargo run -p openhuman-embed --example linux_fleet` + ## Connect an actual MCP protocol stub over loopback Connect an actual MCP protocol stub over loopback. @@ -2661,7 +2748,7 @@ This matrix comes from the default-feature compiled capability-report example. R | `skills` | Yes | | `storage-file` | No | | `storage-mongodb` | No | -| `storage-sqlite` | No | +| `storage-sqlite` | Yes | | `tinymemes` | Yes | | `voice` | No | | `web3` | No | @@ -2799,6 +2886,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`ModelDefaults`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`OpenError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PendingApproval`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`PermissionFuture`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PermissionLevel`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`PickListenPortError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`ProfileError`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -2849,6 +2937,7 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`TrustedAccess`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TrustedAutomationSource`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`Turn`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`TurnCancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnContext`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnOutcome`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`TurnRequest`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -2859,6 +2948,8 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`absolute`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`agent_progress`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`artifacts`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`budget`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`cancellation`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`channels`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`chat_surface`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`complete`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) @@ -2866,20 +2957,25 @@ Generate the complete local Rust API reference with `cargo doc -p openhuman-embe - [`cron`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`embeddings`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`events`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`fanout`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`identity`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`install_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`installed_backend_transport`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`memory`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`modules`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`observe`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`process`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`profiles`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`providers`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`repository`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`routing`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`run_from_args`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`schema_for_rpc_method`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`seams`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`session_store`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`skill_registry`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) - [`stream`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) +- [`structured`](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/src/lib.rs) The compiled capability report describes this build: @@ -2907,7 +3003,7 @@ The compiled capability report describes this build: "skills": true, "storage-file": false, "storage-mongodb": false, - "storage-sqlite": false, + "storage-sqlite": true, "tinymemes": true, "voice": false, "web3": false, @@ -3003,6 +3099,7 @@ The compiled capability report describes this build: - [A per-agent post-turn hook observes completed turns](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/hooks.rs): A per-agent post-turn hook observes completed turns. (offline; optional live via OPENHUMAN_EXAMPLE_LIVE=1 and BASE_URL/API_KEY/MODEL.) - [Host tools and HostOnly keep the advertised tool catalog exact](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/host_tools.rs): Host tools and HostOnly keep the advertised tool catalog exact. (offline with loopback stubs; no live path.) - [Lean runtime without background services](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/lean_headless.rs): Lean runtime without background services. (offline with loopback stubs; optional live via OPENHUMAN_EXAMPLE_LIVE.) +- [Linux agent fleet memory and latency](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/linux_fleet.rs): Measure retained runtime-owned agents using loopback inference and two worker threads. (offline on Linux; use a fresh constrained cgroup for release measurements.) - [Connect an actual MCP protocol stub over loopback](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/mcp.rs): Connect an actual MCP protocol stub over loopback. (offline with loopback stubs; no live path.; feature: mcp) - [Tenant scoped memory facade with an in-memory engine](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/memory.rs): Tenant scoped memory facade with an in-memory engine. (offline with loopback stubs; no live path.) - [SaaS profiles isolate conversation history](https://github.com/tinyhumansai/openhuman/blob/main/crates/openhuman-embed/examples/profiles.rs): SaaS profiles isolate conversation history. (offline with loopback stubs; no live path.) diff --git a/package.json b/package.json index fc0d3a402fb..6d18463fcd3 100644 --- a/package.json +++ b/package.json @@ -56,7 +56,7 @@ "test:install-ps1": "pwsh -NoProfile -File scripts/tests/OpenHumanWindowsInstall.Tests.ps1", "rust:check": "pnpm --filter openhuman-app rust:check", "rust:clippy": "cargo clippy -p openhuman -p openhuman-cli -p openhuman-tinyhumans -- -D warnings && pnpm --filter openhuman-app rust:clippy", - "rust:layout": "node scripts/ci/check-openhuman-rust-layout.mjs && node scripts/ci/check-crate-chain.mjs && node scripts/ci/check-storage-bypass.mjs", + "rust:layout": "node scripts/ci/check-security-module-dependencies.mjs && node scripts/ci/check-openhuman-rust-layout.mjs && node scripts/ci/check-crate-chain.mjs && node scripts/ci/check-storage-bypass.mjs", "rust:ignored-tests": "node scripts/ci/check-ignored-tests.mjs", "dep:audit": "bash scripts/dep-audit/run.sh", "agent:runtime-boundary": "node scripts/ci/check-agent-runtime-boundary.mjs", @@ -78,6 +78,7 @@ "devDependencies": { "gpt-tokenizer": "^3.4.0", "husky": "^9.1.7", + "smol-toml": "1.6.1", "tsx": "^4.20.3", "ws": "^8.20.0" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1749b185551..b812ccecb16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: husky: specifier: ^9.1.7 version: 9.1.7 + smol-toml: + specifier: 1.6.1 + version: 1.6.1 tsx: specifier: ^4.20.3 version: 4.21.0 diff --git a/scripts/__tests__/check-security-module-dependencies.test.mjs b/scripts/__tests__/check-security-module-dependencies.test.mjs new file mode 100644 index 00000000000..bf7d7b50bf9 --- /dev/null +++ b/scripts/__tests__/check-security-module-dependencies.test.mjs @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict'; +import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { test } from 'node:test'; +import { checkManifest, checkRepository, HOST_MANIFESTS } from '../ci/check-security-module-dependencies.mjs'; + +const forms = [ + '[dependencies]\ntinysecurity-policy = "1"', + '[build-dependencies]\nalias = { package = "tinysecurity-policy", version = "1" }', + '[dev-dependencies.alias]\npackage = \'tinysecurity-crypto\'\nversion = "1"', + '[target.\'cfg(windows)\'.dependencies]\nalias = {\n package = "tinysecurity-policy",\n version = "1"\n}', + '[target.\'cfg(unix)\'.build-dependencies."alias"]\npackage = "tinysecurity-keyring"', + '[target.\'cfg(unix)\'.dev-dependencies]\n"tinysecurity-policy" = "1"', + '[workspace.dependencies]\nalias = { package = \'tinysecurity-policy\', version = "1" }', + '[patch.crates-io]\nalias = { package = "tinysecurity-policy", path = "../policy" }', + '[patch."https://github.com/example/repo".alias]\npackage = "tinysecurity-policy"\npath = "../policy"', +]; +for (const [i, source] of forms.entries()) { + test(`rejects forbidden security crate TOML form ${i}`, () => { + assert.equal(checkManifest(source).length, 1); + }); +} +test('allows bus, alias to bus and unrelated packages', () => { + assert.deepEqual(checkManifest('[dependencies]\ntinysecurity-bus = "1"\nfoo = { package = "tinysecurity-bus", version = "1" }\nother = "1"'), []); +}); +test('package alias cannot hide forbidden crate behind bus name', () => { + assert.equal(checkManifest('[dependencies]\ntinysecurity-bus = { package = "tinysecurity-policy", version = "1" }').length, 1); +}); +test('rejects the internal umbrella crate and its aliases', () => { + assert.equal(checkManifest('[dependencies]\ntinysecurity = "1"').length, 1); + assert.equal(checkManifest('[dependencies]\ninternal = { package = "tinysecurity", version = "1" }').length, 1); +}); +test('invalid TOML refuses a pass', () => assert.throws(() => checkManifest('[dependencies\n'), /./)); + +function fixture() { + const root = mkdtempSync(join(tmpdir(), 'security-dependencies-')); + for (const manifest of HOST_MANIFESTS) { + mkdirSync(join(root, manifest, '..'), { recursive: true }); + writeFileSync(join(root, manifest), '[dependencies]\nserde = "1"\n'); + } + return root; +} +test('repository examines root and host manifests while excluding vendor and worktrees', () => { + const root = fixture(); + try { + for (const excluded of ['vendor/module', 'worktrees/other']) { + mkdirSync(join(root, excluded), { recursive: true }); + writeFileSync(join(root, excluded, 'Cargo.toml'), forms[0]); + } + assert.deepEqual(checkRepository(root), []); + writeFileSync(join(root, 'crates/openhuman-cli/Cargo.toml'), forms[1]); + assert.equal(checkRepository(root).length, 1); + } finally { rmSync(root, { recursive: true, force: true }); } +}); +test('missing required manifest and unreadable root refuse vacuous success', () => { + const root = fixture(); + try { + rmSync(join(root, 'crates/openhuman-app/Cargo.toml')); + assert.throws(() => checkRepository(root), /Cargo.toml/); + assert.throws(() => checkRepository(join(root, 'absent')), /ENOENT/); + } finally { rmSync(root, { recursive: true, force: true }); } +}); diff --git a/scripts/__tests__/module-pins.test.mjs b/scripts/__tests__/module-pins.test.mjs index dc1e7ce7599..828f15019ef 100644 --- a/scripts/__tests__/module-pins.test.mjs +++ b/scripts/__tests__/module-pins.test.mjs @@ -554,3 +554,13 @@ test("the submodule probe is not fooled by the superproject above it", () => { rmSync(root, { recursive: true, force: true }); } }); + + +test("conditional registry entries remain covered by the pin gate", () => { + const source = `pub const ALL: &[ModuleRecord] = &[ + TINYSEARCH, + #[cfg(any(feature = "security-module", feature = "test-module"))] + TINYSECURITY, + ];`; + assert.deepEqual(parseAllList(source), ["TINYSEARCH", "TINYSECURITY"]); +}); diff --git a/scripts/__tests__/self-hosted-lanes.test.mjs b/scripts/__tests__/self-hosted-lanes.test.mjs index 2019314eafe..36525261f77 100644 --- a/scripts/__tests__/self-hosted-lanes.test.mjs +++ b/scripts/__tests__/self-hosted-lanes.test.mjs @@ -143,11 +143,12 @@ test("a core-only change still installs the node deps rust-core-coverage's mock `${plan.profile}: rust-core-coverage needs a pnpm install`, ); assert.equal(install.when, true, `${plan.profile}: that install runs`); - // Exactly one install per profile: ex63 lanes share one checkout. + // ex63 lanes share a checkout; hosted scripts and Rust coverage run + // separately, and each needs the TOML parser/mock backend dependencies. const installs = [...checks.values()].filter( (c) => c.when && c.run === "pnpm install --frozen-lockfile", ); - assert.equal(installs.length, 1, plan.profile); + assert.equal(installs.length, plan.profile === "ex63" ? 1 : 2, plan.profile); } const hosted = buildPlan({ profile: "hosted", areas: coreOnly }); const sub = selectLanes(hosted, ["rust-cov"]); @@ -651,3 +652,18 @@ test("the rust-core path filter arms the lane for every crate the tui depends on assert.ok(block.includes(`'crates/openhuman-${crate}/**'`), crate); } }); + + +test("an app-only manifest change runs the security guard with its parser prerequisite", () => { + const areas = { ...NONE, rustTauri: true }; + for (const profile of ["hosted", "ex63"]) { + const plan = buildPlan({ profile, areas, env: EX63_ENV }); + const frontend = plan.lanes.find((lane) => lane.name === "frontend"); + const guard = frontend.checks.find((check) => check.name === "security-module-dependencies"); + const install = frontend.checks.find((check) => check.name === "pnpm-install"); + assert.equal(guard.when, true, profile); + assert.equal(install.when, true, profile); + assert.ok(guard.needs.includes("pnpm-install")); + assert.deepEqual(validatePlan(plan), []); + } +}); diff --git a/scripts/__tests__/stage-modules.test.mjs b/scripts/__tests__/stage-modules.test.mjs index 06b477574dc..cf2b00b4742 100644 --- a/scripts/__tests__/stage-modules.test.mjs +++ b/scripts/__tests__/stage-modules.test.mjs @@ -18,7 +18,10 @@ import { execFileSync } from "node:child_process"; import { gzipSync } from "node:zlib"; import { test } from "node:test"; -import { readRegistrySource } from "../ci/self-hosted/test-module-assets.mjs"; +import { + parseReleaseUrls, + readRegistrySource, +} from "../ci/self-hosted/test-module-assets.mjs"; import { parseAllList } from "../lib/module-pins.mjs"; import { bundledAssets, @@ -41,6 +44,27 @@ const HOST_KEYS = [ "windows-11-arm64", ]; +test("an unpublished module cannot borrow the next record's release URL", () => { + const source = `const PENDING: ModuleRecord = ModuleRecord { + id: "pending", + version: "1.0.0", + release_url: "", + assets: &[], +}; +const RELEASED: ModuleRecord = ModuleRecord { + id: "released", + version: "2.0.0", + release_url: "https://github.com/example/released/releases/tag/v2.0.0", + assets: &[], +};`; + const urls = parseReleaseUrls(source); + assert.equal(urls.has("pending"), false); + assert.equal( + urls.get("released"), + "https://github.com/example/released/releases/tag/v2.0.0", + ); +}); + for (const hostKey of HOST_KEYS) { test(`every compiled module has one pinned ${hostKey} asset`, () => { const source = readRegistrySource(); diff --git a/scripts/ci/check-module-pins.mjs b/scripts/ci/check-module-pins.mjs index 669f42fe3b1..aa4d4ea2e0e 100755 --- a/scripts/ci/check-module-pins.mjs +++ b/scripts/ci/check-module-pins.mjs @@ -75,6 +75,7 @@ const ROOT = resolve(process.argv[2] ?? join(HERE, "..", "..")); // scripts/ci/module-provider-pins.json (their release, and the commit of // `builtAgainst` that release was built from, which must be the host's pin). const PIN_MAP = { + tinysecurity: { submodule: "vendor/tinysecurity" }, tinysearch: { submodule: "vendor/tinysearch" }, tinycomputer: { submodule: "vendor/tinycomputer" }, tinybox: { submodule: "vendor/tinybox" }, diff --git a/scripts/ci/check-security-module-dependencies.mjs b/scripts/ci/check-security-module-dependencies.mjs new file mode 100644 index 00000000000..b69d42d823c --- /dev/null +++ b/scripts/ci/check-security-module-dependencies.mjs @@ -0,0 +1,75 @@ +// Enforce the security module's bus-only boundary before migration begins. +// Parse TOML rather than scanning lines: Cargo supports package aliases, +// per-dependency tables, quoted keys and target-specific dependencies. +import { readFileSync, readdirSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; +import { parse } from 'smol-toml'; + +export const HOST_MANIFESTS = [ + 'Cargo.toml', + ...['core', 'embed', 'tinyhumans', 'rpc', 'app', 'cli', 'tui'].map( + (name) => `crates/openhuman-${name}/Cargo.toml` + ), +]; +const DEPENDENCY_TABLES = new Set(['dependencies', 'build-dependencies', 'dev-dependencies']); + +export function checkManifest(source, filename = 'Cargo.toml') { + const manifest = parse(source); + const violations = []; + function dependencies(table, location) { + for (const [alias, dependency] of Object.entries(table ?? {})) { + const pkg = typeof dependency === 'object' ? dependency.package ?? alias : alias; + if (typeof pkg !== 'string') throw new Error(`${filename}: invalid package in ${location}.${alias}`); + if ((pkg === 'tinysecurity' || pkg.startsWith('tinysecurity-')) && pkg !== 'tinysecurity-bus') { + violations.push(`${filename}: ${location}.${alias} links ${pkg}; only tinysecurity-bus is allowed`); + } + } + } + for (const name of DEPENDENCY_TABLES) { + dependencies(manifest[name], name); + dependencies(manifest.workspace?.[name], `workspace.${name}`); + for (const [target, table] of Object.entries(manifest.target ?? {})) { + dependencies(table[name], `target.${target}.${name}`); + } + } + for (const [registry, table] of Object.entries(manifest.patch ?? {})) { + dependencies(table, `patch.${registry}`); + } + // Cargo's legacy replacement table also contains package references. + for (const [key, dependency] of Object.entries(manifest.replace ?? {})) { + const alias = key.split(':')[0]; + dependencies({ [alias]: dependency }, 'replace'); + } + return violations; +} + +export function checkRepository(root) { + const manifests = new Set(HOST_MANIFESTS); + function walk(dir) { + for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) { + if (['target', 'vendor', 'worktrees', '.git'].includes(entry.name)) continue; + const path = join(dir, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.name === 'Cargo.toml') manifests.add(path); + } + } + // Only the host's crates tree is included; vendored repositories and other + // workflow checkouts independently enforce their own dependency boundaries. + walk('crates'); + return [...manifests].flatMap((path) => checkManifest(readFileSync(join(root, path), 'utf8'), path)); +} + +if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) { + const root = resolve(process.argv[2] ?? join(dirname(fileURLToPath(import.meta.url)), '../..')); + try { + const violations = checkRepository(root); + if (violations.length) { + console.error(violations.join('\n')); + process.exitCode = 1; + } else console.log('Security module dependency boundary: PASS'); + } catch (error) { + console.error(`Security module dependency check refused unreadable or invalid input: ${error.message}`); + process.exitCode = 2; + } +} diff --git a/scripts/ci/module-pin-exemptions.json b/scripts/ci/module-pin-exemptions.json index 8af2cdaf35e..9720a45edb0 100644 --- a/scripts/ci/module-pin-exemptions.json +++ b/scripts/ci/module-pin-exemptions.json @@ -11,16 +11,5 @@ "Delete an entry the moment the pins are reconciled. An exemption that has", "stopped being true fails the gate too \u2014 `expect` must still match." ], - "exemptions": [ - { - "id": "tinywallet", - "expect": "v0.7.4-6-gaccb920a", - "reason": "The vendored tree includes six unreleased commits; the shipped v0.7.4 artifact remains the last published runtime module. Remove this exception when the next artifact is published and both pins advance." - }, - { - "id": "tinychannels", - "expect": "v0.1.12-3-g5e3b2044", - "reason": "The vendored tree includes three unreleased commits; the shipped v0.1.12 artifact remains the last published runtime module. Remove this exception when the next artifact is published and both pins advance." - } - ] + "exemptions": [] } diff --git a/scripts/ci/product-features.txt b/scripts/ci/product-features.txt index a780310460c..1b49f3a4cf2 100644 --- a/scripts/ci/product-features.txt +++ b/scripts/ci/product-features.txt @@ -54,6 +54,7 @@ documents # this build trusts, and the `modules` RPC namespace. Required by `documents` — # without it the document tools have nothing to call. modules +security-module # Saved automation graphs: create/run/schedule + the workflow_builder and # flow_discovery agents. diff --git a/scripts/ci/security-native-fixture.sh b/scripts/ci/security-native-fixture.sh new file mode 100644 index 00000000000..15b5327645a --- /dev/null +++ b/scripts/ci/security-native-fixture.sh @@ -0,0 +1,31 @@ +#!/usr/bin/env bash +# Exercise the released module through the compiled registry and archive digest. +# For explicit local module development, set OPENHUMAN_TEST_SECURITY_MODULE. +set -euo pipefail + +if [[ -z "${OPENHUMAN_TEST_SECURITY_MODULE:-}" ]]; then + case "$(uname -s)" in + Darwin) + case "$(uname -m)" in + arm64) security_host="macos-15-arm64" ;; + x86_64) security_host="macos-15-x86_64" ;; + *) echo "Unsupported macOS architecture" >&2; exit 1 ;; + esac ;; + Linux) + case "$(uname -m)" in + aarch64) security_host="ubuntu-22.04-arm64" ;; + x86_64) security_host="ubuntu-22.04-x86_64" ;; + *) echo "Unsupported Linux architecture" >&2; exit 1 ;; + esac ;; + MINGW* | MSYS* | CYGWIN*) security_host="windows-2025-x86_64" ;; + *) echo "Unsupported native security fixture host" >&2; exit 1 ;; + esac + export OPENHUMAN_TEST_SECURITY_RELEASE_HOST="${OPENHUMAN_TEST_SECURITY_RELEASE_HOST:-$security_host}" +fi + +export OPENHUMAN_TEST_SECURITY_FIXTURE_DIR="$HOME/.cache/openhuman-security-fixtures" +mkdir -p "$OPENHUMAN_TEST_SECURITY_FIXTURE_DIR" + +bash scripts/ci-cancel-aware.sh cargo test -p openhuman --lib \ + --no-default-features --features inference,web3,modules,security-module \ + modules::security -- --include-ignored --nocapture diff --git a/scripts/ci/self-hosted/lanes-plan.mjs b/scripts/ci/self-hosted/lanes-plan.mjs index d0558e6ffb3..80c90458dff 100644 --- a/scripts/ci/self-hosted/lanes-plan.mjs +++ b/scripts/ci/self-hosted/lanes-plan.mjs @@ -261,10 +261,10 @@ export function buildPlan({ profile, areas, env = {}, isPullRequest = true }) { checks: [ { name: "pnpm-install", - // ex63: also for a core change, so rust-core-coverage's mock backend - // (scripts/mock-api-server.mjs imports `ws`) can start when no - // frontend file changed. One install per VM: lanes share a checkout. - when: areas.frontend || areas.i18n || areas.scripts || (ex63 && core), + // All Rust changes run the bus-only dependency guard, whose TOML + // parser is a root package dependency. ex63 shares this install + // with rust-core-coverage's mock backend; hosted lanes are separate. + when: areas.frontend || areas.i18n || areas.scripts || rust, run: "pnpm install --frozen-lockfile", }, { @@ -309,6 +309,12 @@ export function buildPlan({ profile, areas, env = {}, isPullRequest = true }) { run: "pnpm docs:test", }, { name: "docs-drift", when: areas.docs, run: "pnpm docs:check" }, + { + name: "security-module-dependencies", + when: areas.scripts || rust, + needs: ["pnpm-install"], + run: "node scripts/ci/check-security-module-dependencies.mjs", + }, { name: "scripts-self-tests", when: areas.scripts, diff --git a/scripts/ci/self-hosted/test-module-assets.mjs b/scripts/ci/self-hosted/test-module-assets.mjs index 7741faabda0..73ce05ff55d 100644 --- a/scripts/ci/self-hosted/test-module-assets.mjs +++ b/scripts/ci/self-hosted/test-module-assets.mjs @@ -33,9 +33,12 @@ export function readRegistrySource(dir = REGISTRY_DIR) { /** `release_url` per record id, from the same source text. */ export function parseReleaseUrls(src) { const urls = new Map(); - const re = /id: "([^"]+)",[\s\S]*?release_url: "([^"]+)"/g; - for (const m of src.matchAll(re)) { - if (!urls.has(m[1])) urls.set(m[1], m[2]); + // Keep fields inside their record. A global regex can skip an empty URL + // and accidentally associate the following module's release with this id. + for (const record of parseRecords(src).values()) { + if (record.id && record.releaseUrl && !urls.has(record.id)) { + urls.set(record.id, record.releaseUrl); + } } return urls; } diff --git a/scripts/lib/module-pins.mjs b/scripts/lib/module-pins.mjs index 2be1c53f59a..70abd414bb2 100644 --- a/scripts/lib/module-pins.mjs +++ b/scripts/lib/module-pins.mjs @@ -49,6 +49,9 @@ export function parseAllList(src) { if (!block) throw new Error("registry.rs: could not find `pub const ALL`"); return block[1] .replace(/\/\/[^\n]*/g, "") + // Inspect every possible feature set. Attributes annotate the next record; + // commas inside cfg(any(...)) must not split the record list. + .replace(/#\[cfg\([^\]]*\)\]/g, "") .split(",") .map((s) => s.trim()) .filter((s) => s.length > 0); diff --git a/tests/fixtures/security-redaction-corpus.json b/tests/fixtures/security-redaction-corpus.json new file mode 100644 index 00000000000..785222d1488 --- /dev/null +++ b/tests/fixtures/security-redaction-corpus.json @@ -0,0 +1,104 @@ +{ + "text": [ + { + "case": "anthropic_key", + "input": "sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789", + "needle": "abcdefghijklmnopqrstuvwxyz0123456789", + "removed_by": [ + "prefix", + "identity_hash", + "log", + "host_scrub", + "denials", + "credential_middleware" + ], + "composio_secret": "sk-ant-api03-abcdefghijklmnopqrstuvwxyz0123456789" + }, + { + "case": "email", + "input": "alice@example.com", + "needle": "alice@example.com", + "removed_by": [ + "prefix", + "identity_hash", + "pii" + ], + "composio_secret": "unrelated-configured-key" + }, + { + "case": "linux_home", + "input": "/home/alice/report.txt", + "needle": "alice", + "removed_by": [ + "prefix", + "identity_hash", + "approval" + ], + "composio_secret": "unrelated-configured-key" + }, + { + "case": "macos_home", + "input": "/Users/alice/report.txt", + "needle": "alice", + "removed_by": [ + "prefix", + "identity_hash", + "approval" + ], + "composio_secret": "unrelated-configured-key" + }, + { + "case": "windows_home", + "input": "C:\\Users\\alice\\report.txt", + "needle": "alice", + "removed_by": [ + "prefix", + "identity_hash", + "approval" + ], + "composio_secret": "unrelated-configured-key" + }, + { + "case": "windows_home_doubled", + "input": "C:\\\\Users\\\\alice\\\\report.txt", + "needle": "alice", + "removed_by": [ + "prefix", + "identity_hash" + ], + "composio_secret": "unrelated-configured-key" + }, + { + "case": "ordinary_text", + "input": "hello world", + "needle": "hello world", + "removed_by": [ + "prefix", + "identity_hash" + ], + "composio_secret": "unrelated-configured-key" + } + ], + "urls": [ + { + "case": "basic_auth_and_query", + "input": "https://alice:hunter2@api.example.com/v1?key=secret#private", + "util": "https://redacted:redacted@api.example.com/v1?key=secret#private", + "storage": "https://***@api.example.com/v1?***", + "migration": "https://api.example.com/v1", + "endpoint": "https://api.example.com", + "inference": "api.example.com", + "wallet": "https://api.example.com" + }, + { + "case": "plain_url", + "input": "https://api.example.com/v1", + "util": "https://api.example.com/v1", + "storage": "https://api.example.com/v1", + "migration": "https://api.example.com/v1", + "endpoint": "https://api.example.com", + "inference": "api.example.com", + "wallet": "https://api.example.com" + } + ] +} diff --git a/tests/security_characterization_e2e.rs b/tests/security_characterization_e2e.rs new file mode 100644 index 00000000000..8b4a1a6d17b --- /dev/null +++ b/tests/security_characterization_e2e.rs @@ -0,0 +1,369 @@ +//! Phase-zero security wire characterization through the authenticated production +//! router. No server socket, inference, Docker process, or OS keychain is used. +//! Existing sibling domain suites cover command/path policy, gate TTL/origins, +//! restart durability and sandbox precedence; these tests pin their RPC boundary. +//! Coverage retained beside the owning domains: +//! - approval/gate_tests.rs (+ gate_core_flow/gate_origin_intercept/gate_ttl_and_triage/ +//! gate_subagent/gate_forced tests): allowlist, park/decide, origin and TTL rules. +//! - approval/store_persistence_tests.rs and store_flow_trust_tests.rs: restart +//! durability and flow trust; gate_triage_tests.rs pins blanket bypass/no audit. +//! - policy/policy_disabled_tests.rs, policy_paths_and_risk_tests.rs, +//! policy_trusted_roots_tests.rs: disabled floor, command/path and rate rules. +//! - sandbox/ops_tests.rs: SaaS/env/agent precedence and Noop status. +//! Private Composio and credential middleware tests consume the same corpus +//! beside their owning modules, without widening production visibility. + +#[path = "support/env_guard.rs"] +mod env_guard; + +use axum::{ + body::{to_bytes, Body}, + http::{Request, StatusCode}, + Router, +}; +use openhuman_core::{ + config::Config, + security::approval::{gate::ApprovalGate, store, types::PendingApproval}, +}; +use serde_json::{json, Value}; +use tower::ServiceExt; + +const TOKEN: &str = "security-characterization-test-token"; + +async fn rpc(router: &Router, method: &str, params: Value) -> Value { + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/rpc") + .header("content-type", "application/json") + .header("authorization", format!("Bearer {TOKEN}")) + .body(Body::from( + json!({"jsonrpc":"2.0","id":73,"method":method,"params":params}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK, "{method}"); + let value: Value = + serde_json::from_slice(&to_bytes(response.into_body(), 1024 * 1024).await.unwrap()) + .unwrap(); + assert_eq!(value["jsonrpc"], "2.0"); + assert_eq!(value["id"], 73); + value +} + +fn result(response: &Value) -> &Value { + assert!(response.get("error").is_none(), "{response}"); + let result = &response["result"]; + // Controllers with logs preserve the existing inner result/logs envelope. + if result.get("logs").is_some() { + &result["result"] + } else { + result + } +} + +#[tokio::test] +async fn approval_policy_sandbox_and_secret_wire_contracts() { + let _lock = env_guard::env_lock_async().await; + let scratch = tempfile::tempdir().unwrap(); + let _workspace = env_guard::EnvVarGuard::set_path("OPENHUMAN_WORKSPACE", scratch.path()); + let _keyring = env_guard::EnvVarGuard::set("OPENHUMAN_KEYRING_BACKEND", "file"); + let _sandbox = env_guard::EnvVarGuard::unset("OPENHUMAN_SANDBOX"); + let _rate = env_guard::EnvVarGuard::unset("OPENHUMAN_MAX_ACTIONS_PER_HOUR"); + let _storage = env_guard::EnvVarGuard::unset("OPENHUMAN_STORAGE_URL"); + let _action = + env_guard::EnvVarGuard::set_path("OPENHUMAN_ACTION_DIR", &scratch.path().join("action")); + let mut config = Config::default(); + config.workspace_dir = scratch.path().to_path_buf(); + config.config_path = scratch.path().join("config.toml"); + config.action_dir = scratch.path().join("action"); + config.action_dir_override = Some(config.action_dir.clone()); + config.save().await.unwrap(); + openhuman_core::core::auth::init_rpc_token_with_value(TOKEN).unwrap(); + openhuman_core::security::keyring::init_workspace(scratch.path()); + let router = openhuman_rpc::server::build_core_http_router(false); + + assert_eq!( + result(&rpc(&router, "openhuman.approval_list_pending", json!({})).await), + &json!([]) + ); + assert_eq!( + result(&rpc(&router, "openhuman.approval_get_gate_state", json!({})).await), + &json!({"installed":false,"disabledByEnv":false,"overrideIgnored":false,"host":"unknown"}) + ); + assert_eq!( + result( + &rpc( + &router, + "openhuman.approval_preauthorize_flow", + json!({"flow_id":"flow-before-boot","tool_names":["shell"]}) + ) + .await + ), + &json!({"flow_id":"flow-before-boot","granted":[],"already_trusted":[],"gate_installed":false}) + ); + let missing_gate = rpc( + &router, + "openhuman.approval_decide", + json!({"request_id":"missing","decision":"deny"}), + ) + .await; + assert!(missing_gate["error"]["message"] + .as_str() + .unwrap() + .contains("gate is not installed")); + + ApprovalGate::init_global(config.clone(), "session-security-characterization"); + let pending = PendingApproval::new( + "orphan-from-previous-boot", + "shell", + "execute command", + json!({"command":"echo hello"}), + Some(chrono::Utc::now() + chrono::Duration::minutes(10)), + ); + store::insert_pending(&config, &pending, "session-previous-boot").unwrap(); + let listed = rpc(&router, "openhuman.approval_list_pending", json!({})).await; + assert_eq!( + result(&listed), + &serde_json::to_value(vec![pending.clone()]).unwrap() + ); + assert!(result(&listed)[0].get("session_id").is_none()); + let decided = rpc( + &router, + "openhuman.approval_decide", + json!({"request_id":pending.request_id,"decision":"deny"}), + ) + .await; + assert_eq!(result(&decided), &serde_json::to_value(&pending).unwrap()); + assert_eq!( + result(&rpc(&router, "openhuman.approval_list_pending", json!({})).await), + &json!([]) + ); + let repeated = rpc( + &router, + "openhuman.approval_decide", + json!({"request_id":pending.request_id,"decision":"deny"}), + ) + .await; + assert!(repeated["error"]["message"] + .as_str() + .unwrap() + .contains("already decided or expired")); + let grants = rpc( + &router, + "openhuman.approval_preauthorize_flow", + json!({"flow_id":"flow-1","tool_names":["shell","shell"," "]}), + ) + .await; + assert_eq!( + result(&grants), + &json!({"flow_id":"flow-1","granted":["shell"],"already_trusted":[],"gate_installed":true}) + ); + let again = rpc( + &router, + "openhuman.approval_preauthorize_flow", + json!({"flow_id":"flow-1","tool_names":["shell"]}), + ) + .await; + assert_eq!( + result(&again), + &json!({"flow_id":"flow-1","granted":[],"already_trusted":["shell"],"gate_installed":true}) + ); + + let policy = rpc(&router, "openhuman.security_policy_info", json!({})).await; + let expected = json!({ + "autonomy": config.autonomy.level, + "workspace_only": config.autonomy.workspace_only, + "allowed_commands": config.autonomy.allowed_commands, + "max_actions_per_hour": config.autonomy.max_actions_per_hour, + "require_approval_for_medium_risk": config.autonomy.require_approval_for_medium_risk, + "block_high_risk_commands": config.autonomy.block_high_risk_commands, + }); + assert_eq!(result(&policy), &expected); + assert_eq!(result(&policy).as_object().unwrap().len(), 6); + + let resolved = rpc( + &router, + "openhuman.sandbox_resolve_policy", + json!({"sandbox_mode":"none"}), + ) + .await; + assert_eq!(result(&resolved)["backend"], "none"); + let validated = rpc( + &router, + "openhuman.sandbox_validate_policy", + json!({"policy":result(&resolved)}), + ) + .await; + assert_eq!(result(&validated), &json!({"valid":true,"issues":[]})); + let status = rpc( + &router, + "openhuman.sandbox_status", + json!({"backend":"none"}), + ) + .await; + assert_eq!(result(&status)["kind"], "none"); + assert_eq!(result(&status)["status"], "ready"); + + // These are the actual existing namespace names; encryption.* is absent. + let encrypted = rpc( + &router, + "openhuman.encrypt_secret", + json!({"plaintext":"synthetic secret"}), + ) + .await; + let ciphertext = result(&encrypted).as_str().unwrap(); + assert!(ciphertext.starts_with("enc2:")); + assert!(!ciphertext.contains("synthetic secret")); + assert_eq!( + result( + &rpc( + &router, + "openhuman.decrypt_secret", + json!({"ciphertext":ciphertext}) + ) + .await + ), + "synthetic secret" + ); + assert_eq!( + result( + &rpc( + &router, + "openhuman.decrypt_secret", + json!({"ciphertext":"legacy plaintext"}) + ) + .await + ), + "legacy plaintext" + ); + assert!(rpc( + &router, + "openhuman.decrypt_secret", + json!({"ciphertext":"enc2:invalid"}) + ) + .await + .get("error") + .is_some()); + assert!( + rpc(&router, "openhuman.encrypt_secret", json!({"plaintext":17})) + .await + .get("error") + .is_some() + ); +} + +#[tokio::test] +async fn in_process_policy_latency_baseline() { + // Measure the existing tool decision hot path, with policy enforcement on. + // No time threshold: record the baseline before choosing a bus budget. + let scratch = tempfile::tempdir().unwrap(); + let mut config = Config::default(); + config.autonomy.enabled = true; + let action = scratch.path().join("action"); + let state = scratch.path().join("state"); + std::fs::create_dir_all(&action).unwrap(); + std::fs::create_dir_all(&state).unwrap(); + let policy = + openhuman_core::security::SecurityPolicy::from_config(&config.autonomy, &state, &action); + let path = action.join("baseline.txt").display().to_string(); + let evaluate = || { + assert!(std::hint::black_box(policy.check_gated_command("ls")).is_ok()); + assert!(std::hint::black_box(policy.is_path_string_allowed(&path))); + }; + for _ in 0..100 { + evaluate(); + } + let mut samples = Vec::with_capacity(10_000); + for _ in 0..10_000 { + let start = std::time::Instant::now(); + evaluate(); + samples.push(start.elapsed().as_nanos()); + } + samples.sort_unstable(); + println!( + "security in-process baseline: samples={} checks_per_sample=2 p50_ns={} p99_ns={}", + samples.len(), + samples[4999], + samples[9899] + ); +} + +#[test] +fn shared_redaction_corpus_records_existing_catches_and_gaps() { + let corpus: Value = + serde_json::from_str(include_str!("fixtures/security-redaction-corpus.json")).unwrap(); + for case in corpus["text"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + let needle = case["needle"].as_str().unwrap(); + // Unknown-key approval args exercise text/path scrubbing rather than + // the separate sensitive-field blanket replacement. + let approval = + openhuman_core::security::approval::redact::redact_args(&json!({"payload":input})); + openhuman_core::tools::registry::denials::record( + "characterization", + "test", + "blocked", + input, + ); + let denial = openhuman_core::tools::registry::denials::list(1) + .pop() + .unwrap() + .reason; + let outputs = [ + ("denials", denial), + ("prefix", openhuman_core::security::redact(input)), + ("identity_hash", openhuman_core::util::redact::redact(input)), + ( + "log", + openhuman_core::core::log_redaction::scrub_secrets(input), + ), + ( + "host_scrub", + openhuman_core::security::scrub::sanitize_text(input).value, + ), + ( + "approval", + approval["payload"].as_str().unwrap().to_string(), + ), + ( + "pii", + openhuman_core::security::pii::redact_identifiers(input), + ), + ]; + for (name, output) in outputs { + let removed = case["removed_by"] + .as_array() + .unwrap() + .iter() + .any(|v| v == name); + assert_eq!( + !output.contains(needle), + removed, + "case={} redactor={name} output={output:?}", + case["case"] + ); + } + } +} + +#[test] +fn shared_url_redaction_corpus_pins_query_preservation_gap() { + let corpus: Value = + serde_json::from_str(include_str!("fixtures/security-redaction-corpus.json")).unwrap(); + for case in corpus["urls"].as_array().unwrap() { + let input = case["input"].as_str().unwrap(); + assert_eq!( + openhuman_core::util::redact::redact_url_for_log(input), + case["util"] + ); + assert_eq!( + openhuman_core::config::schema::storage::redact_url(input), + case["storage"] + ); + } +} diff --git a/vendor/tinybox b/vendor/tinybox index b61c6bcebf3..6e413449dc7 160000 --- a/vendor/tinybox +++ b/vendor/tinybox @@ -1 +1 @@ -Subproject commit b61c6bcebf3c0dbaf6ce3e36fae8131ebd6d78ae +Subproject commit 6e413449dc7afedbef9f2ab46d61ebe46061abe1 diff --git a/vendor/tinysecurity b/vendor/tinysecurity new file mode 160000 index 00000000000..b196016a383 --- /dev/null +++ b/vendor/tinysecurity @@ -0,0 +1 @@ +Subproject commit b196016a383276acc7c0ead0e4c285faf62eb35c