diff --git a/MODULE.md b/MODULE.md index fde66018..92ecc935 100644 --- a/MODULE.md +++ b/MODULE.md @@ -127,6 +127,9 @@ Full details: [paired-device lifecycle specification](docs/specs/device-pairing- ## Browser destination authorization (contract 1.28) Full details: [browser dial specification](docs/specs/browser-dial-contract-1.28.md). +## Host skill-scan decisions (contract 1.29) +Full details: [host skill-scan specification](docs/specs/skill-scan-contract-1.29.md). + ## Confidential injected master resolution Full details: [`Confidential injected master resolution`](docs/module-crypto.md#confidential-injected-master-resolution). diff --git a/README.md b/README.md index d1bb41e4..343cca07 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,12 @@ privacy modes deny this uninspectable traffic; TinyComputer remains responsible for enforcing the proxy and dialing the approved socket. See the [browser-dial contract specification](docs/specs/browser-dial-contract-1.28.md). +Contract 1.29 adds `Check::SkillScan` so the trusted host can submit the scan +verdict and exact bundle digest before an installed skill runs. `Block` is a +content denial, `Warn` enters the host's approval flow, and `Pass` leaves the +remaining policy checks authoritative. See the +[host skill-scan specification](docs/specs/skill-scan-contract-1.29.md). + Contract 1.6 supplies `Audit2`, `EvaluateAudited2` and `CheckAudited2` with authenticated exact audit callbacks and a bounded [native journal](crates/tinysecurity-audit/README.md). The audited permission methods derive events from the actual original request, diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 020b77bd..ba2479f3 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -100,6 +100,19 @@ pub(crate) fn native_safe_call(request: &EvaluateRequest) -> Result { } Ok(safe_call) } + +fn contains_blocked_skill_scan(checks: &[Check]) -> bool { + checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + .. + } + ) + }) +} + pub(crate) fn original( reg: &ApprovalScope2, request: &EvaluateRequest, @@ -113,6 +126,7 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 + || contains_blocked_skill_scan(&request.call.checks) || (reg.ceiling == AccessTier::Read && request.caller.tier != AccessTier::Read) || (reg.ceiling == AccessTier::Write && request.caller.tier == AccessTier::Full) || request @@ -137,7 +151,7 @@ pub(crate) fn original( args.sort_all_objects(); let canonical = serde_json::to_string(&args).map_err(|_| Error::Invalid)?; let external = request.call.checks.iter().any(|check| match check { - Check::LocalDiagnostic {} => false, + Check::LocalDiagnostic {} | Check::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index 82f2a51e..7ead684e 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -760,7 +760,17 @@ impl Engine { now: u64, native: bool, ) -> Result<(Option, bool)> { + let scan_warning = request.request.call.checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Warn, + .. + } + ) + }); let human_only = native + || scan_warning || matches!( request.request.call.tool.as_str(), "sandbox_host_network" | "device_crypto" diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 11bfd348..64c6a6e1 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -513,6 +513,33 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu Ok(()) } +#[tokio::test] +async fn skill_scan_warning_enters_human_approval() -> Result<()> { + let (_, owner) = owner()?; + let mut auto_approve = register(7); + auto_approve.scope.auto_approve_all = true; + auto_approve.grants = grants(&["run_workflow"], &[]); + owner.register_scope(auto_approve).await?; + + let mut warning = begin_for(7, "run_workflow", None, Origin::Operator); + warning.request.call.checks = vec![Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Warn, + bundle_sha256: "a".repeat(64), + }]; + let pending = owner.begin(warning).await?; + assert_eq!(pending.state, ApprovalLifecycle2::Pending); + assert!(!pending.external_effect); + + let mut blocked = begin_for(7, "run_workflow", None, Origin::Operator); + blocked.request.caller.call_id = "blocked-call".into(); + blocked.request.call.checks = vec![Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + bundle_sha256: "b".repeat(64), + }]; + assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied)); + Ok(()) +} + #[tokio::test] async fn host_action_and_shell_checks_bind_with_their_own_external_effect() -> Result<()> { let (_, owner) = owner()?; diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index 1d5dedb9..ec64d356 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,5 @@ fn frame_operations_redact_both_payloads_without_changing_open_wire_shape() #[test] fn frame_member_name_is_stable_and_contract_version_is_bumped() { assert_eq!(crate::names::methods::DEVICE_FRAME2, "DeviceFrame2"); - assert_eq!(crate::CONTRACT_VERSION, (1, 28)); + assert_eq!(crate::CONTRACT_VERSION, (1, 29)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index c417a301..ce8974fe 100644 --- a/crates/tinysecurity-bus/src/device_pairing_tests.rs +++ b/crates/tinysecurity-bus/src/device_pairing_tests.rs @@ -85,7 +85,7 @@ fn refresh_and_close_are_distinct_transient_session_operations() { #[test] fn pairing_contract_is_versioned_after_the_frame_contract() { - assert_eq!(CONTRACT_VERSION, (1, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 0c13ca7f..633dab84 100644 --- a/crates/tinysecurity-bus/src/lib.rs +++ b/crates/tinysecurity-bus/src/lib.rs @@ -65,7 +65,7 @@ pub use scoped_policy::*; pub use tool_trust::*; pub mod names; /// Current wire vocabulary version, independent of the artifact version. -pub const CONTRACT_VERSION: (u32, u32) = (1, 28); +pub const CONTRACT_VERSION: (u32, u32) = (1, 29); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 5b34d141..8c3ba1cc 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -75,6 +75,22 @@ pub struct ToolCall { /// Never forward checks supplied by a model, tool client or remote caller. pub checks: Vec, } + +/// Host-computed verdict from scanning a skill before execution (contract 1.29). +/// The host must scan the exact installed document and resources it will expose; +/// this value is a trusted host assertion, never a model-supplied claim. It is +/// supplemental to the host-effect check and cannot authorize a tool call alone. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum SkillScanVerdict { + /// No findings require action. + Pass, + /// Findings require the existing host approval flow before execution. + Warn, + /// The scan found content that must not execute. + Block, +} + /// A check of a concrete operation. The bootstrap has no URL authorization. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] @@ -141,6 +157,15 @@ pub enum Check { #[serde(default, skip_serializing_if = "Option::is_none")] command_policy: Option, }, + /// A trusted host's scan result for the exact skill bundle being executed + /// (contract 1.29). `Warn` requires host approval; `Block` is a hard denial. + SkillScan { + /// Result computed by the host scanner. + verdict: SkillScanVerdict, + /// Lowercase SHA-256 of the exact document and resources the host will + /// expose during this run. The approval binding includes this value. + bundle_sha256: String, + }, /// An effect not supported by this engine. Unknown { /// Content-free descriptor. diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index d560df78..a1ae5c84 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -118,6 +118,31 @@ fn host_shell_has_a_stable_strict_wire_form() -> TestResult { Ok(()) } +#[test] +fn skill_scan_has_stable_verdict_wire_forms() -> TestResult { + for (verdict, wire) in [ + (SkillScanVerdict::Pass, "pass"), + (SkillScanVerdict::Warn, "warn"), + (SkillScanVerdict::Block, "block"), + ] { + let check = Check::SkillScan { + verdict, + bundle_sha256: "a".repeat(64), + }; + let value = serde_json::to_value(&check)?; + assert_eq!( + value, + serde_json::json!({ + "kind":"skill_scan", + "verdict":wire, + "bundle_sha256":"a".repeat(64), + }) + ); + assert_eq!(serde_json::from_value::(value)?, check); + } + Ok(()) +} + #[test] fn new_denial_reasons_have_stable_wire_forms_and_old_ones_are_unchanged() -> TestResult { for (reason, wire) in [ diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index 1b329b83..5c5b79f3 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -1,4 +1,4 @@ -//! Registered subject policy wire shapes and the 1.28 compatibility boundary. +//! Registered subject policy wire shapes and the 1.29 compatibility boundary. use serde_json::json; use tinysecurity_bus::*; @@ -87,10 +87,10 @@ fn new_members_are_advertised_and_named_by_contract_constants() { #[test] fn contract_bump_is_additive_for_older_hosts_and_modules() { - assert_eq!(CONTRACT_VERSION, (1, 28)); - // A host built against 1.28 accepts this module and newer minors, not a 1.27 module. - assert!(is_compatible((1, 28))); - assert!(!is_compatible((1, 27))); + assert_eq!(CONTRACT_VERSION, (1, 29)); + // A host built against 1.29 accepts this module and newer minors, not a 1.28 module. + assert!(is_compatible((1, 29))); + assert!(!is_compatible((1, 28))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); assert!(!is_compatible((1, 24))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index 361a1aa6..ac0a2e2f 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -343,7 +343,7 @@ fn redactor_init_is_additive_and_secrets_never_enter_public_info() serde_json::to_value(&config)?["redactor"]["secrets"][0], "literal-private" ); - assert_eq!(CONTRACT_VERSION, (1, 28)); + assert_eq!(CONTRACT_VERSION, (1, 29)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 7fb5e759..25e0c3aa 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1572,13 +1572,18 @@ fn permission_payload(mut original: serde_json::Value) -> BusResult { fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { tinybus::Error::failed(error.to_string()) } -/// Whether every check is a host-tool or shell admission question (contract 1.20). +/// Whether host-tool checks are admission-only, with an optional supplemental scan. fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool { use tinysecurity_bus::Check; - !checks.is_empty() - && checks - .iter() - .all(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. })) + checks + .iter() + .any(|check| matches!(check, Check::HostAction { .. } | Check::HostShell { .. })) + && checks.iter().all(|check| { + matches!( + check, + Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. } + ) + }) } fn deny_allow(decision: &mut Decision) { if matches!(decision.verdict, tinysecurity_bus::Verdict::Allow) { diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 8f2a680c..c93cde7b 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -112,6 +112,52 @@ async fn evaluate_reads_and_spends_the_named_command_scopes_budget() -> TestResu Ok(()) } +#[tokio::test] +async fn evaluate_applies_host_skill_scan_verdicts_before_run_workflow() -> TestResult { + let service = service()?; + for (verdict, expected) in [ + ( + tinysecurity_bus::SkillScanVerdict::Block, + Verdict::Deny { + reason: DenialReason::Content, + }, + ), + ( + tinysecurity_bus::SkillScanVerdict::Warn, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }, + ), + (tinysecurity_bus::SkillScanVerdict::Pass, Verdict::Allow), + ] { + assert_eq!( + evaluate( + &service, + request( + "run_workflow", + vec![ + Check::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + }, + Check::SkillScan { + verdict, + bundle_sha256: "c".repeat(64), + }, + ], + ), + ) + .await? + .verdict, + expected, + "{verdict:?}" + ); + } + Ok(()) +} + #[tokio::test] async fn evaluate_gates_a_shell_command_under_the_named_scope() -> TestResult { let service = service()?; @@ -242,6 +288,14 @@ async fn a_mixed_request_is_not_an_admission_and_stays_audit_bound() -> TestResu assert!(!admission_only(&call.call.checks)); call.call.checks = vec![acting(false, &scope)]; assert!(admission_only(&call.call.checks)); + let scan = Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Pass, + bundle_sha256: "a".repeat(64), + }; + call.call.checks = vec![acting(false, &scope), scan.clone()]; + assert!(admission_only(&call.call.checks)); + call.call.checks = vec![scan]; + assert!(!admission_only(&call.call.checks)); Ok(()) } diff --git a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs index 9dca7fc8..29da820c 100644 --- a/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_cipher_fixture_tests.rs @@ -499,6 +499,12 @@ impl Control { #[tokio::test] async fn actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed() -> TestResult { + if isolate_process_owned_fixture( + "actual_confidential_cipher_dispatch_stages_only_at_fixed_slots_and_fails_closed", + "cipher", + )? { + return Ok(()); + } for mode in 20..=32 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-module/src/adapter_secret_tests.rs b/crates/tinysecurity-module/src/adapter_secret_tests.rs index 08159815..8e3b6fd1 100644 --- a/crates/tinysecurity-module/src/adapter_secret_tests.rs +++ b/crates/tinysecurity-module/src/adapter_secret_tests.rs @@ -4,6 +4,29 @@ use std::sync::atomic::{AtomicU32, Ordering}; use tinybus::{Connection, broker::Broker, transport::memory::MemoryBus}; use tinysecurity_bus::*; type TestResult = Result<(), Box>; + +/// The module's confidential authorities are process-owned and intentionally +/// survive service-tree replacement. Run actual bus fixtures in a child test +/// process so Windows' linked module loader cannot share those owners between +/// independent broker instances in the same libtest process. +fn isolate_process_owned_fixture( + test_filter: &str, + marker: &str, +) -> Result> { + const CHILD_MARKER: &str = "TINYSECURITY_PROCESS_OWNED_FIXTURE"; + if std::env::var(CHILD_MARKER).is_ok_and(|value| value == marker) { + return Ok(false); + } + let status = std::process::Command::new(std::env::current_exe()?) + .arg(test_filter) + .arg("--nocapture") + .env(CHILD_MARKER, marker) + .status()?; + if !status.success() { + return Err(format!("isolated fixture process exited with {status}").into()); + } + Ok(true) +} #[derive(Debug)] struct FixedClock(std::sync::atomic::AtomicU64); impl tinysecurity::approval::Clock for FixedClock { @@ -922,6 +945,12 @@ mod linked_fixture { #[tokio::test] async fn actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed() -> TestResult { + if isolate_process_owned_fixture( + "actual_sdk_confidential_master_resolution_is_human_only_exact_and_fail_closed", + "master", + )? { + return Ok(()); + } for mode in 0..=19 { let bus = MemoryBus::new(); let owner = Broker::new(); diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 0c6c7788..a28b1d82 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -138,7 +138,9 @@ impl Policy { request.call.checks.iter().any(|c| { matches!( c, - Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } + Check::HostTool {} + | Check::HostAction { .. } + | Check::HostShell { .. } ) }) && request .call @@ -303,6 +305,22 @@ impl Policy { *declared_class, ) }), + Check::SkillScan { + verdict, + bundle_sha256, + } => { + if is_sha256(bundle_sha256) { + match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + } + } else { + Err(DenialReason::InvalidContext) + } + } Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { @@ -349,6 +367,13 @@ impl Policy { } } } + +fn is_sha256(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) +} /// Validate one subject's base settings with the same rules as module configuration. pub(crate) fn validate_settings(p: &PolicyConfig) -> Result<()> { if p.enabled && (!path::valid_root(&p.action_dir) || !path::valid_root(&p.workspace_dir)) { diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 86754d71..6f08d5d8 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -1,5 +1,14 @@ use super::*; -use tinysecurity_bus::ToolCall; +use tinysecurity_bus::{SkillScanVerdict, ToolCall, ToolPermission}; + +fn non_effecting_host_action() -> Check { + Check::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + } +} #[test] fn direct_credential_transfers_deny_original_content() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; @@ -56,6 +65,15 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { Ok(policy.evaluate(request)?.verdict) } +#[test] +fn additive_contract_minor_accepts_an_older_same_major_host() { + let config = ModuleConfig { + contract_version: (1, 28), + ..Default::default() + }; + assert!(Policy::new(config).is_ok()); +} + #[test] fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; @@ -88,6 +106,124 @@ fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { Ok(()) } +#[test] +fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + for (scan_verdict, expected) in [ + ( + SkillScanVerdict::Block, + Verdict::Deny { + reason: DenialReason::Content, + }, + ), + ( + SkillScanVerdict::Warn, + Verdict::Deny { + reason: DenialReason::ApprovalUnavailable, + }, + ), + (SkillScanVerdict::Pass, Verdict::Allow), + ] { + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: scan_verdict, + bundle_sha256: "a".repeat(64), + }, + ], + ), + )?, + expected, + "{scan_verdict:?}" + ); + } + Ok(()) +} + +#[test] +fn skill_scan_pass_cannot_authorize_a_host_tool_without_an_effect_check() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256: "a".repeat(64), + }], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + Ok(()) +} + +#[test] +fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: SkillScanVerdict::Warn, + bundle_sha256: "b".repeat(64), + }, + Check::Unknown { + description: "unsupported operation".into(), + }, + ], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + Ok(()) +} + +#[test] +fn a_skill_scan_requires_a_canonical_bundle_digest() -> Result<()> { + let policy = Policy::new(ModuleConfig::default())?; + for bundle_sha256 in [ + String::new(), + "A".repeat(64), + "g".repeat(64), + "a".repeat(63), + ] { + assert_eq!( + verdict( + &policy, + &host_call( + "run_workflow", + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256, + }, + ], + ), + )?, + Verdict::Deny { + reason: DenialReason::InvalidContext, + } + ); + } + Ok(()) +} + #[test] fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; diff --git a/crates/tinysecurity-policy/src/scoped.rs b/crates/tinysecurity-policy/src/scoped.rs index 8ab24ddd..07c726f0 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,6 +106,14 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), + Check::SkillScan { + verdict, + bundle_sha256, + } => { + field(h, "skill_scan"); + field(h, &format!("{verdict:?}")); + field(h, bundle_sha256); + } Check::HostAction { permission, external_effect, diff --git a/docs/README.md b/docs/README.md index ef76e074..411d12ad 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ docs/ - **[`specs/device-pairing-contract-1.25.md`](specs/device-pairing-contract-1.25.md)** — host-attested paired-device lifecycle contract and trust boundaries. - **[`plans/device-pairing-contract-1.25.md`](plans/device-pairing-contract-1.25.md)** — ordered implementation and verification steps for pairing lifecycle support. - **[`specs/browser-dial-contract-1.28.md`](specs/browser-dial-contract-1.28.md)** — destination-only, one-use socket authorization for opaque browser traffic. +- **[`specs/skill-scan-contract-1.29.md`](specs/skill-scan-contract-1.29.md)** — host skill-scan verdicts and approval behavior before execution. - **[`plans/`](plans/README.md)** — implementation-ordered, test-first steps for delivering an approved specification. Plans name exact files and verification commands, and are updated as the work progresses. diff --git a/docs/plans/skill-scan-contract-1.29.md b/docs/plans/skill-scan-contract-1.29.md new file mode 100644 index 00000000..a19c2269 --- /dev/null +++ b/docs/plans/skill-scan-contract-1.29.md @@ -0,0 +1,72 @@ +# Host skill-scan decisions (contract 1.29) implementation plan + +Specification: [host skill-scan decisions](../specs/skill-scan-contract-1.29.md). + +Status: implementation is under review in TinySecurity PR #48. OpenHuman host +integration remains tracked in OpenHuman #7328 and must wait for the released +1.29 module artifact. + +## Goal and boundaries + +Add an authenticated host-supplied skill-scan verdict to policy evaluation and +bind it to the exact skill bundle digest. TinySecurity consumes the host's +verdict; TinySkills continues to own bundle parsing and scanning. The check +never authorizes a tool by itself, and older 1.x hosts retain their prior +behavior until they send the new check. + +## Ordered work + +1. **Define the wire contract.** Add the verdict and digest fields to + `crates/tinysecurity-bus/src/policy.rs` and export them from + `crates/tinysecurity-bus/src/lib.rs`. First pin verdict round trips, + malformed digests, and additive 1.x compatibility in + `crates/tinysecurity-bus/src/policy_tests.rs`, + `crates/tinysecurity-bus/tests/scoped_wire.rs`, and + `crates/tinysecurity-bus/tests/wire.rs`. +2. **Evaluate policy.** Implement `Pass`, `Warn`, and `Block` precedence in + `crates/tinysecurity-policy/src/scoped.rs` and `engine.rs`. Add failing + tests in `crates/tinysecurity-policy/src/engine_tests.rs` for hard block, + human-only warning, malformed context, and cache exclusion before changing + the evaluator. +3. **Bind human approval.** Include the verdict and bundle digest in approval + identity and prevent auto-approval, grants, prior decisions, or judge + results from satisfying a warning in + `crates/tinysecurity-approval/src/binding.rs` and `engine.rs`. Pin the + lifecycle and scope behavior in `engine_scopes_tests.rs` first. +4. **Wire the native module.** Dispatch the check through + `crates/tinysecurity-module/src/adapter.rs`. Add adapter regressions in + `adapter_host_tool_tests.rs`, and verify module-owned approval/callback + behavior through `adapter_secret_tests.rs` and + `adapter_secret_cipher_fixture_tests.rs` before completing the dispatch. +5. **Document compatibility and verify.** Keep `MODULE.md`, `README.md`, + `docs/README.md`, and this specification aligned. Update this plan's + checklist as the module tests and hosted platform matrix complete. + +## Verification + +Run from the TinySecurity repository root: + +```sh +cargo fmt --all -- --check +cargo test -p tinysecurity-bus skill_scan_has_stable_verdict_wire_forms +cargo test -p tinysecurity-bus contract_bump_is_additive_for_older_hosts_and_modules +cargo test -p tinysecurity-policy host_skill_scan +cargo test -p tinysecurity-approval skill_scan_warning_enters_human_approval +cargo test -p tinysecurity-module evaluate_applies_host_skill_scan_verdicts_before_run_workflow +scripts/ci-cancel-aware.sh cargo test --quiet -p tinysecurity-bus -p tinysecurity-policy -p tinysecurity-approval +scripts/ci-cancel-aware.sh cargo clippy -p tinysecurity-bus -p tinysecurity-policy -p tinysecurity-approval -p tinysecurity-module --all-targets --all-features -- -D warnings +``` + +The required module CI also covers Linux, macOS, Windows, release latency, +coverage, and the native module fixture. Record these as complete only after +the exact PR head passes each required job. + +## Completion checklist + +- [x] Additive bus contract and wire compatibility tests. +- [x] Policy precedence and non-cacheable scan binding. +- [x] Human-only warning approval and hard-block behavior. +- [x] Native module dispatch and adapter coverage. +- [x] Specification and module documentation updated. +- [ ] Review thread resolved and exact-head CI green. +- [ ] Merge and publish the 1.29 module artifact before OpenHuman adopts it. diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md new file mode 100644 index 00000000..071c5bed --- /dev/null +++ b/docs/specs/skill-scan-contract-1.29.md @@ -0,0 +1,68 @@ +# Host skill-scan decisions (contract 1.29) + +**Status:** Accepted; implementation is under review in TinySecurity PR #48. +**Owner:** TinySecurity bus contract and policy engine. +**Plan:** [implementation plan](../plans/skill-scan-contract-1.29.md). + +## Problem + +OpenHuman's TinySkills scanner has `Pass`, `Warn`, and `Block` outcomes, but +the security policy bus cannot represent those outcomes before a skill runs. +Without a typed check, a warning cannot enter the standard approval resolver +and a block cannot be distinguished from ordinary tool permission. + +## Goals and non-goals + +- Let an authenticated host submit its scan verdict to the policy engine. +- Require human approval for warnings and make blocks non-overridable. +- Bind the result to the exact installed bundle using a digest. +- Keep TinySkills parsing and scanning in TinySkills; TinySecurity consumes only + the host-computed verdict. +- Do not let models or remote callers assert scan results. + +## Proposed behavior + +`Check::SkillScan` carries the trusted host's `Pass`, `Warn`, or `Block` result +for the exact installed skill bundle an agent is about to execute. The request +also carries a lowercase SHA-256 digest over the document and every resource +the run can expose. TinySecurity binds that digest into the approval request; +it does not read skill files or trust a verdict supplied by a model or caller. +This check is supplemental: the host must include the normal host-effect check +in the same call, and a scan result alone never authorizes a tool. + +The host must derive the verdict from its scanner over the same bytes the run +will consume. It must preserve that snapshot for the run, or verify the digest +again before exposing any resource. A changed bundle requires a fresh scan and +policy evaluation. The digest is an approval binding, not a content signature +or a substitute for the host's authenticated bus adapter. + +Policy maps the verdicts as follows: + +- `Pass` succeeds this check; all other policy checks still apply. +- `Warn` returns `ApprovalUnavailable`, which the host maps to its existing + human-approval path. It is human-only: auto-approve-all, a whole-tool or flow + grant, a previous approval, and the judge cannot bypass this fresh decision. +- `Block` returns `DenialReason::Content`, a hard refusal that approval cannot + override. +- A malformed digest returns `DenialReason::InvalidContext`. + +The check is not reusable by registered-policy decision caching. Its verdict +and bundle digest remain in the canonical call fingerprint and approval +binding, so a changed document cannot reuse the earlier warning decision. + +Contract 1.29 is additive for older same-major hosts. Such hosts retain the +1.28 behavior and do not gain skill-scan enforcement until their host adapter +sends this check. OpenHuman pins an exact module artifact in its registry, so +the host adapter and a module artifact that supports 1.29 must ship together. + +## Acceptance criteria + +- The wire type round-trips all verdicts and requires a bundle digest. +- `Pass` allows this check, `Warn` produces the existing approval requirement, + and `Block` produces `DenialReason::Content`. +- Malformed digests deny with `DenialReason::InvalidContext`. +- A warning enters the persisted human-approval lifecycle; a block cannot be + converted into a pending approval by policy evaluation. +- Auto-approve-all, grants, and judge decisions cannot bypass a warning's + fresh human decision. +- A 1.29 host rejects a 1.28 module, and accepts later 1.x module minors.