From 88f5c4fdd57e24131433b363147dcc666443212c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:10:33 +0000 Subject: [PATCH 01/25] test(policy): cover host skill scan verdicts and denial precedence Add engine tests asserting that block and warn skill scan verdicts deny with the expected reasons while pass allows, and that a warning does not mask an independent hard denial from another check. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 86754d71..ae681e6a 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -88,6 +88,64 @@ 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 (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![Check::SkillScan { verdict }], + ), + )?, + expected, + "{verdict:?}" + ); + } + 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![ + Check::SkillScan { + verdict: SkillScanVerdict::Warn, + }, + Check::Unknown { + description: "unsupported operation".into(), + }, + ], + ), + )?, + Verdict::Deny { + reason: DenialReason::UnsupportedEffect, + } + ); + Ok(()) +} + #[test] fn host_tool_never_relaxes_other_checks_and_rejects_reserved_or_unmarked_calls() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; From dae1135e5f1d2a0ffa80c861059874033cea4a4c Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:11 +0000 Subject: [PATCH 02/25] feat(bus): add skill scan verdict check to policy contract Add a SkillScan check carrying a host-computed pass, warn or block verdict for the exact skill bundle being executed, so hosts can gate execution on scan results rather than trusting model-supplied claims. Bump the contract version to 1.29 and cover the new wire forms with tests. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/device_frame_tests.rs | 2 +- .../src/device_pairing_tests.rs | 2 +- crates/tinysecurity-bus/src/lib.rs | 2 +- crates/tinysecurity-bus/src/policy.rs | 21 +++++++++++++++++++ crates/tinysecurity-bus/src/policy_tests.rs | 18 ++++++++++++++++ crates/tinysecurity-bus/tests/scoped_wire.rs | 10 ++++----- crates/tinysecurity-bus/tests/wire.rs | 2 +- crates/tinysecurity-policy/src/engine.rs | 12 ++++++++++- crates/tinysecurity-policy/src/scoped.rs | 4 ++++ 9 files changed, 63 insertions(+), 10 deletions(-) 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..eb7bc5d0 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -75,6 +75,21 @@ 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. +#[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 +156,12 @@ 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, + }, /// 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..5436f4bc 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -118,6 +118,24 @@ 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 }; + let value = serde_json::to_value(&check)?; + assert_eq!( + value, + serde_json::json!({"kind":"skill_scan", "verdict":wire}) + ); + 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-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 0c6c7788..b511fd8d 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -138,7 +138,10 @@ impl Policy { request.call.checks.iter().any(|c| { matches!( c, - Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } + Check::HostTool {} + | Check::HostAction { .. } + | Check::HostShell { .. } + | Check::SkillScan { .. } ) }) && request .call @@ -303,6 +306,13 @@ impl Policy { *declared_class, ) }), + Check::SkillScan { verdict } => match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + }, Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { diff --git a/crates/tinysecurity-policy/src/scoped.rs b/crates/tinysecurity-policy/src/scoped.rs index 8ab24ddd..aefc5c34 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,6 +106,10 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), + Check::SkillScan { verdict } => { + field(h, "skill_scan"); + field(h, &format!("{verdict:?}")); + } Check::HostAction { permission, external_effect, From f205ed7b59f311fd6c75bc3e0fa8b53990b2e556 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:34 +0000 Subject: [PATCH 03/25] fix(approval): treat skill scan checks as non-external The binding logic now classifies SkillScan checks as non-external, matching LocalDiagnostic, so they no longer mark a request as having external effects. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 020b77bd..65e18084 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -138,6 +138,7 @@ pub(crate) fn original( 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::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. From f6256bb3419918c4404f5fb68197c5ae56b6b707 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:44 +0000 Subject: [PATCH 04/25] test(policy): import SkillScanVerdict in engine tests Extend the engine test imports to bring in SkillScanVerdict alongside ToolCall, preparing the test module for assertions that depend on skill scan verdicts. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index ae681e6a..bf21c341 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -1,5 +1,5 @@ use super::*; -use tinysecurity_bus::ToolCall; +use tinysecurity_bus::{SkillScanVerdict, ToolCall}; #[test] fn direct_credential_transfers_deny_original_content() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; From 9a950f53ef357a6d083dde2db695fd362e08fcf2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:11:53 +0000 Subject: [PATCH 05/25] test(tinysecurity-policy): rename loop variable in skill scan verdict test Renamed the loop binding to scan_verdict so it no longer shadows the verdict field name used when constructing the Check::SkillScan value, keeping the assertion message consistent with the new name. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index bf21c341..ea2b2f2d 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -91,7 +91,7 @@ fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { #[test] fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; - for (verdict, expected) in [ + for (scan_verdict, expected) in [ ( SkillScanVerdict::Block, Verdict::Deny { @@ -111,11 +111,13 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res &policy, &host_call( "run_workflow", - vec![Check::SkillScan { verdict }], + vec![Check::SkillScan { + verdict: scan_verdict, + }], ), )?, expected, - "{verdict:?}" + "{scan_verdict:?}" ); } Ok(()) From fecf3bc720f2c6e4c303a5a9a95ef11d4236ccad Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:12:36 +0000 Subject: [PATCH 06/25] feat(policy): bind skill scan approvals to bundle digest Skill scan checks now carry a lowercase SHA-256 of the document and resources the host will expose, and the engine denies the call with InvalidContext when that digest is missing or malformed. This ties an approval to the exact bundle that was scanned so a warning cannot be reused against different content. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/policy.rs | 3 ++ crates/tinysecurity-bus/src/policy_tests.rs | 11 ++++++-- crates/tinysecurity-policy/src/engine.rs | 28 +++++++++++++++---- .../tinysecurity-policy/src/engine_tests.rs | 25 +++++++++++++++++ crates/tinysecurity-policy/src/scoped.rs | 6 +++- 5 files changed, 64 insertions(+), 9 deletions(-) diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index eb7bc5d0..c3a1e78a 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -161,6 +161,9 @@ pub enum Check { 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 { diff --git a/crates/tinysecurity-bus/src/policy_tests.rs b/crates/tinysecurity-bus/src/policy_tests.rs index 5436f4bc..a1ae5c84 100644 --- a/crates/tinysecurity-bus/src/policy_tests.rs +++ b/crates/tinysecurity-bus/src/policy_tests.rs @@ -125,11 +125,18 @@ fn skill_scan_has_stable_verdict_wire_forms() -> TestResult { (SkillScanVerdict::Warn, "warn"), (SkillScanVerdict::Block, "block"), ] { - let check = Check::SkillScan { verdict }; + 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}) + serde_json::json!({ + "kind":"skill_scan", + "verdict":wire, + "bundle_sha256":"a".repeat(64), + }) ); assert_eq!(serde_json::from_value::(value)?, check); } diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index b511fd8d..90f503f0 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -306,13 +306,22 @@ impl Policy { *declared_class, ) }), - Check::SkillScan { verdict } => match verdict { - tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), - tinysecurity_bus::SkillScanVerdict::Warn => { - Err(DenialReason::ApprovalUnavailable) + Check::SkillScan { + verdict, + bundle_sha256, + } => { + if !is_sha256(bundle_sha256) { + Err(DenialReason::InvalidContext) + } else { + match verdict { + tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), + tinysecurity_bus::SkillScanVerdict::Warn => { + Err(DenialReason::ApprovalUnavailable) + } + tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), + } } - tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), - }, + } Check::Path { path, write } => path::check(settings, path, *write) .and_then(|()| hooks.map_or(Ok(()), |h| (h.path)(path, *write))) .and_then(|()| { @@ -359,6 +368,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 ea2b2f2d..c6da00cf 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -113,6 +113,7 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res "run_workflow", vec![Check::SkillScan { verdict: scan_verdict, + bundle_sha256: "a".repeat(64), }], ), )?, @@ -134,6 +135,7 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { vec![ Check::SkillScan { verdict: SkillScanVerdict::Warn, + bundle_sha256: "b".repeat(64), }, Check::Unknown { description: "unsupported operation".into(), @@ -148,6 +150,29 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { 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![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 aefc5c34..07c726f0 100644 --- a/crates/tinysecurity-policy/src/scoped.rs +++ b/crates/tinysecurity-policy/src/scoped.rs @@ -106,9 +106,13 @@ fn check_fields(h: &mut Sha256, check: &Check) { } Check::LocalDiagnostic {} => field(h, "local_diagnostic"), Check::HostTool {} => field(h, "host_tool"), - Check::SkillScan { verdict } => { + Check::SkillScan { + verdict, + bundle_sha256, + } => { field(h, "skill_scan"); field(h, &format!("{verdict:?}")); + field(h, bundle_sha256); } Check::HostAction { permission, From 893ef9c9fe416e67a8d4529c3548a46db658ad51 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:12:55 +0000 Subject: [PATCH 07/25] docs: document host skill-scan contract 1.29 Add references to the new skill-scan specification across the module and documentation indexes. The contract introduces Check::SkillScan so the trusted host can submit a scan verdict and exact bundle digest before an installed skill runs, with Block denying content, Warn entering the host approval flow, and Pass leaving remaining policy checks authoritative. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- MODULE.md | 3 +++ README.md | 6 ++++++ docs/README.md | 1 + docs/specs/skill-scan-contract-1.29.md | 26 ++++++++++++++++++++++++++ 4 files changed, 36 insertions(+) create mode 100644 docs/specs/skill-scan-contract-1.29.md 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/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/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md new file mode 100644 index 00000000..7e7e1cc8 --- /dev/null +++ b/docs/specs/skill-scan-contract-1.29.md @@ -0,0 +1,26 @@ +# Host skill-scan decisions (contract 1.29) + +`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. + +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 never silently becomes an allow. +- `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. From 5c4abe5e652b861ef87faa9d6df4a8bf1988fab3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:12 +0000 Subject: [PATCH 08/25] test(approval): cover skill scan warn and block verdicts Add a test asserting that a skill scan warning routes the request into human approval without being treated as an external effect, while a block verdict is denied outright and cannot be overridden. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 11bfd348..fd6b871c 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -513,6 +513,30 @@ 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_and_block_cannot_be_overridden() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).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()?; From b8be187bca4c981c181ef412c0db467f72a85ce2 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:24 +0000 Subject: [PATCH 09/25] test(approval): assert denied lifecycle state instead of error The skill scan block test now checks that begin returns a denied approval carrying the Denied lifecycle state, rather than expecting an error. This matches the engine's actual behaviour of surfacing denials as state. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/engine_scopes_tests.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index fd6b871c..0793759d 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -533,7 +533,8 @@ async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden verdict: tinysecurity_bus::SkillScanVerdict::Block, bundle_sha256: "b".repeat(64), }]; - assert_eq!(owner.begin(blocked).await.err(), Some(Error::Denied)); + let denied = owner.begin(blocked).await?; + assert_eq!(denied.state, ApprovalLifecycle2::Denied); Ok(()) } From d2d118713d5e1e438e14f3ac8da17a00b1b4686d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:13:44 +0000 Subject: [PATCH 10/25] test(approval): narrow skill scan test to human approval path The test no longer asserts that a skill scan block verdict is denied, so its name was shortened to match what it actually covers. The removed block duplicated coverage that lives elsewhere. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-approval/src/engine_scopes_tests.rs | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 0793759d..3410d771 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -514,7 +514,7 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu } #[tokio::test] -async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden() -> Result<()> { +async fn skill_scan_warning_enters_human_approval() -> Result<()> { let (_, owner) = owner()?; owner.register_scope(register(7)).await?; @@ -526,15 +526,6 @@ async fn skill_scan_warning_enters_human_approval_and_block_cannot_be_overridden 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), - }]; - let denied = owner.begin(blocked).await?; - assert_eq!(denied.state, ApprovalLifecycle2::Denied); Ok(()) } From 5599b28a9533870d9cb4d1f2c78c1c480a23826b Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:14:01 +0000 Subject: [PATCH 11/25] docs: expand skill-scan contract 1.29 spec Add problem statement, goals and non-goals, and acceptance criteria to the skill-scan contract so the draft status and ownership are explicit. Reformat the bundle digest test loop to satisfy rustfmt. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 7 +++- docs/specs/skill-scan-contract-1.29.md | 32 +++++++++++++++++++ 2 files changed, 38 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index c6da00cf..fa88112d 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -153,7 +153,12 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { #[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)] { + for bundle_sha256 in [ + String::new(), + "A".repeat(64), + "g".repeat(64), + "a".repeat(63), + ] { assert_eq!( verdict( &policy, diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index 7e7e1cc8..13ce8ece 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -1,5 +1,26 @@ # Host skill-scan decisions (contract 1.29) +**Status:** Draft, implemented on `skill-scan-risk-7328`. +**Owner:** TinySecurity bus contract and policy engine. + +## 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 @@ -24,3 +45,14 @@ Policy maps the verdicts as follows: 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. + +## 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. +- Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later + 1.x module minors. From a5fdeccc9095519e29a40aa9f7f30a245e686bd7 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:17:13 +0000 Subject: [PATCH 12/25] test(approval): cover skill scan block under auto-approve scope The skill scan warning test now registers its scope with auto-approve enabled and a run_workflow grant, so the warning path is exercised even when the scope would otherwise skip human approval. A second call with a blocking skill scan verdict is asserted to be denied outright, confirming that a block overrides auto-approval. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/engine_scopes_tests.rs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 3410d771..64c6a6e1 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -516,7 +516,10 @@ async fn host_tool_marker_is_an_external_effect_that_cron_cannot_reach() -> Resu #[tokio::test] async fn skill_scan_warning_enters_human_approval() -> Result<()> { let (_, owner) = owner()?; - owner.register_scope(register(7)).await?; + 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 { @@ -526,6 +529,14 @@ async fn skill_scan_warning_enters_human_approval() -> Result<()> { 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(()) } From 09fdf075ca6caaa2d521720d757f05d9fb737300 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:17:31 +0000 Subject: [PATCH 13/25] fix(approval): require human approval for blocked and warned skill scans Requests carrying a skill scan verdict of Block are now treated as non-original, and a Warn verdict forces human-only approval. This keeps flagged skills from being auto-approved by the native path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 9 +++++++++ crates/tinysecurity-approval/src/engine.rs | 10 ++++++++++ 2 files changed, 19 insertions(+) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 65e18084..12ce8307 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -113,6 +113,15 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 + || request.call.checks.iter().any(|check| { + matches!( + check, + Check::SkillScan { + verdict: tinysecurity_bus::SkillScanVerdict::Block, + .. + } + ) + }) || (reg.ceiling == AccessTier::Read && request.caller.tier != AccessTier::Read) || (reg.ceiling == AccessTier::Write && request.caller.tier == AccessTier::Full) || request 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" From 2237f3b29a87659b5a8ae7fa3baea15b8ea1dea5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:18:12 +0000 Subject: [PATCH 14/25] test(adapter-host): cover host skill scan verdicts before run_workflow Add a test asserting that run_workflow maps host skill scan verdicts to the expected policy outcomes, with Block denying on content, Warn denying as approval unavailable, and Pass allowing. The spec is clarified to state that a warning requires a fresh human decision and cannot be bypassed by auto-approve-all, grants, prior approvals, or the judge. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_host_tool_tests.rs | 38 +++++++++++++++++++ docs/specs/skill-scan-contract-1.29.md | 5 ++- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 8f2a680c..011c975c 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -112,6 +112,44 @@ 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::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()?; diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index 13ce8ece..e1ed19b6 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -37,7 +37,8 @@ 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 never silently becomes an allow. + 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`. @@ -54,5 +55,7 @@ binding, so a changed document cannot reuse the earlier warning decision. - 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. - Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later 1.x module minors. From 758a77927d23b81ba663d01f5d12a5085acff616 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:18:34 +0000 Subject: [PATCH 15/25] refactor(approval): extract blocked skill scan check and flatten conditionals Pulled the blocked skill scan predicate into a named helper and merged the matching arms in the external-effect check. Inverted the bundle hash guard in the policy engine so the success path reads first, with no change in behaviour. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-approval/src/binding.rs | 26 ++++++++++++--------- crates/tinysecurity-policy/src/engine.rs | 6 ++--- 2 files changed, 18 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index 12ce8307..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,15 +126,7 @@ pub(crate) fn original( || request.call.arguments_json.len() > 32768 || request.call.checks.is_empty() || request.call.checks.len() > 128 - || request.call.checks.iter().any(|check| { - matches!( - check, - Check::SkillScan { - verdict: tinysecurity_bus::SkillScanVerdict::Block, - .. - } - ) - }) + || 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 @@ -146,8 +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::SkillScan { .. } => false, + Check::LocalDiagnostic {} | Check::SkillScan { .. } => false, Check::Path { write, .. } => *write, Check::HostAction { external_effect, .. diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index 90f503f0..e8d9ab69 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -310,9 +310,7 @@ impl Policy { verdict, bundle_sha256, } => { - if !is_sha256(bundle_sha256) { - Err(DenialReason::InvalidContext) - } else { + if is_sha256(bundle_sha256) { match verdict { tinysecurity_bus::SkillScanVerdict::Pass => Ok(()), tinysecurity_bus::SkillScanVerdict::Warn => { @@ -320,6 +318,8 @@ impl Policy { } tinysecurity_bus::SkillScanVerdict::Block => Err(DenialReason::Content), } + } else { + Err(DenialReason::InvalidContext) } } Check::Path { path, write } => path::check(settings, path, *write) From e94c7236780efdbc62ae7128ca019008b28286e9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:26:40 +0000 Subject: [PATCH 16/25] test(policy): cover skill scan pass without effect check Add a test asserting that a passing skill scan alone cannot authorize a host tool call, which must still be denied as an unsupported effect. This locks in that skill scan verdicts never substitute for an explicit effect check. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../tinysecurity-policy/src/engine_tests.rs | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index fa88112d..8685447a 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -124,6 +124,27 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res 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())?; From 4adef47348662b163c65ddc86dcfcea521caee22 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:27:10 +0000 Subject: [PATCH 17/25] fix(policy): stop treating skill scan checks as host tool calls Skill scan checks were matched by the host tool call arm in the policy engine, so a request carrying only a skill scan was evaluated as a host tool invocation. The arm no longer matches skill scans, and the affected tests now pair the scan with a non-effecting host action so they still exercise the intended path. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../src/adapter_host_tool_tests.rs | 16 ++++++--- crates/tinysecurity-policy/src/engine.rs | 1 - .../tinysecurity-policy/src/engine_tests.rs | 34 ++++++++++++++----- 3 files changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 011c975c..95a0fe0f 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -135,10 +135,18 @@ async fn evaluate_applies_host_skill_scan_verdicts_before_run_workflow() -> Test &service, request( "run_workflow", - vec![Check::SkillScan { - verdict, - bundle_sha256: "c".repeat(64), - }], + vec![ + Check::HostAction { + permission: ToolPermission::None, + external_effect: false, + spend: false, + command_policy: None, + }, + Check::SkillScan { + verdict, + bundle_sha256: "c".repeat(64), + }, + ], ), ) .await? diff --git a/crates/tinysecurity-policy/src/engine.rs b/crates/tinysecurity-policy/src/engine.rs index e8d9ab69..a28b1d82 100644 --- a/crates/tinysecurity-policy/src/engine.rs +++ b/crates/tinysecurity-policy/src/engine.rs @@ -141,7 +141,6 @@ impl Policy { Check::HostTool {} | Check::HostAction { .. } | Check::HostShell { .. } - | Check::SkillScan { .. } ) }) && request .call diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 8685447a..ba936d66 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::{SkillScanVerdict, 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())?; @@ -111,10 +120,13 @@ fn host_skill_scan_verdicts_deny_block_and_require_approval_for_warning() -> Res &policy, &host_call( "run_workflow", - vec![Check::SkillScan { - verdict: scan_verdict, - bundle_sha256: "a".repeat(64), - }], + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: scan_verdict, + bundle_sha256: "a".repeat(64), + }, + ], ), )?, expected, @@ -154,6 +166,7 @@ fn a_skill_warning_never_masks_an_independent_hard_denial() -> Result<()> { &host_call( "run_workflow", vec![ + non_effecting_host_action(), Check::SkillScan { verdict: SkillScanVerdict::Warn, bundle_sha256: "b".repeat(64), @@ -185,10 +198,13 @@ fn a_skill_scan_requires_a_canonical_bundle_digest() -> Result<()> { &policy, &host_call( "run_workflow", - vec![Check::SkillScan { - verdict: SkillScanVerdict::Pass, - bundle_sha256, - }], + vec![ + non_effecting_host_action(), + Check::SkillScan { + verdict: SkillScanVerdict::Pass, + bundle_sha256, + }, + ], ), )?, Verdict::Deny { From 2f8566479ece503dbf4a2fa4fab385558ad0627e Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:29:16 +0000 Subject: [PATCH 18/25] docs: clarify skill scan is supplemental and additive for 1.28 hosts The skill-scan contract now states that a scan verdict is supplemental to the host-effect check and can never authorize a tool call on its own. It also documents that 1.29 is additive for older same-major hosts, which keep 1.28 behavior until their adapter sends the check, and corrects the acceptance criteria to describe a 1.29 host rejecting a 1.28 module. A test asserts that an older same-major host config is still accepted. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-bus/src/policy.rs | 3 ++- crates/tinysecurity-policy/src/engine_tests.rs | 8 ++++++++ docs/specs/skill-scan-contract-1.29.md | 10 ++++++++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index c3a1e78a..8c3ba1cc 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -78,7 +78,8 @@ pub struct ToolCall { /// 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. +/// 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 { diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index ba936d66..756ab7d2 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -65,6 +65,14 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { Ok(policy.evaluate(request)?.verdict) } +#[test] +fn additive_contract_minor_accepts_an_older_same_major_host() -> Result<()> { + let mut config = ModuleConfig::default(); + config.contract_version = (1, 28); + assert!(Policy::new(config).is_ok()); + Ok(()) +} + #[test] fn host_tool_needs_approval_instead_of_being_unsupported() -> Result<()> { let policy = Policy::new(ModuleConfig::default())?; diff --git a/docs/specs/skill-scan-contract-1.29.md b/docs/specs/skill-scan-contract-1.29.md index e1ed19b6..830dd030 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -26,6 +26,8 @@ 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 @@ -47,6 +49,11 @@ 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. @@ -57,5 +64,4 @@ binding, so a changed document cannot reuse the earlier warning decision. converted into a pending approval by policy evaluation. - Auto-approve-all, grants, and judge decisions cannot bypass a warning's fresh human decision. -- Older 1.28 hosts reject the 1.29 module, and the 1.29 host accepts later - 1.x module minors. +- A 1.29 host rejects a 1.28 module, and accepts later 1.x module minors. From 8dfbb39585ee4ae98610cf3c9d03025377e4dd5a Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:32:37 +0000 Subject: [PATCH 19/25] test(policy): simplify contract version test setup Replace the mutable default config and explicit Result return with struct update syntax so the test no longer needs to construct and mutate a binding or wrap its assertion in Ok. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-policy/src/engine_tests.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 756ab7d2..6f08d5d8 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -66,11 +66,12 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { } #[test] -fn additive_contract_minor_accepts_an_older_same_major_host() -> Result<()> { - let mut config = ModuleConfig::default(); - config.contract_version = (1, 28); +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()); - Ok(()) } #[test] From 77218603ebf68feaf2c394004beb8b67d0243e0d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:35:31 +0000 Subject: [PATCH 20/25] test(adapter-host-tool): cover skill scan checks in admission-only detection Extend the mixed-request test to assert that a skill scan check alongside an acting check still counts as admission-only, while a skill scan on its own does not. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter_host_tool_tests.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs index 95a0fe0f..c93cde7b 100644 --- a/crates/tinysecurity-module/src/adapter_host_tool_tests.rs +++ b/crates/tinysecurity-module/src/adapter_host_tool_tests.rs @@ -288,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(()) } From d9d2590c6d1dc936560760ab96c89f1f7614040d Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:06 +0000 Subject: [PATCH 21/25] fix(adapter): treat skill scans as admission-only checks The admission_only predicate now accepts SkillScan checks alongside host action and shell checks, and requires at least one host-tool or shell question to be present. This keeps skill scans from being rejected as non-admission checks under contract 1.20. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 7fb5e759..ae3fb889 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1575,10 +1575,14 @@ fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { /// Whether every check is a host-tool or shell admission question (contract 1.20). 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) { From 24ef88e2cc32f73cf4de33deba2583a5fa2291cb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:13 +0000 Subject: [PATCH 22/25] style: reformat admission_only check for readability Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index ae3fb889..dd044cb6 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1575,14 +1575,15 @@ fn approval_failure(error: tinysecurity::approval::Error) -> tinybus::Error { /// Whether every check is a host-tool or shell admission question (contract 1.20). fn admission_only(checks: &[tinysecurity_bus::Check]) -> bool { use tinysecurity_bus::Check; - checks.iter().any(|check| { - matches!(check, Check::HostAction { .. } | Check::HostShell { .. }) - }) && checks.iter().all(|check| { - matches!( - check, - Check::HostAction { .. } | Check::HostShell { .. } | Check::SkillScan { .. } - ) - }) + 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) { From 0e8e631ae95755b84950b6f04c97c5992211b647 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 04:36:37 +0000 Subject: [PATCH 23/25] docs(tinysecurity-module): clarify admission_only doc comment Update the doc comment on admission_only to describe host-tool checks as admission-only with an optional supplemental scan, replacing the outdated contract version reference. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- crates/tinysecurity-module/src/adapter.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index dd044cb6..25e0c3aa 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1572,7 +1572,7 @@ 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 From e7f1b3254e3711ac379a83d4127c82e04a0b5698 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 05:24:34 +0000 Subject: [PATCH 24/25] test: isolate process-owned confidential fixtures in child processes Confidential authorities are process-owned and survive service-tree replacement, so running the actual bus fixtures in the same libtest process let Windows' linked module loader share owners between independent broker instances. The affected tests now re-execute themselves in a child process marked by an environment variable and skip the in-process run. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- .../adapter_secret_cipher_fixture_tests.rs | 6 ++++ .../src/adapter_secret_tests.rs | 29 +++++++++++++++++++ 2 files changed, 35 insertions(+) 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(); From 65aa790ed329b7892920690c1915b459560044a9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 23 Oct 2026 08:09:41 +0000 Subject: [PATCH 25/25] docs(skill-scan): mark contract 1.29 as accepted Update the contract status from draft to accepted now that implementation is under review in TinySecurity PR #48, and link the new implementation plan alongside it. Auto-committed-on: cuttlefish Co-authored-by: Medulla --- docs/plans/skill-scan-contract-1.29.md | 72 ++++++++++++++++++++++++++ docs/specs/skill-scan-contract-1.29.md | 3 +- 2 files changed, 74 insertions(+), 1 deletion(-) create mode 100644 docs/plans/skill-scan-contract-1.29.md 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 index 830dd030..071c5bed 100644 --- a/docs/specs/skill-scan-contract-1.29.md +++ b/docs/specs/skill-scan-contract-1.29.md @@ -1,7 +1,8 @@ # Host skill-scan decisions (contract 1.29) -**Status:** Draft, implemented on `skill-scan-risk-7328`. +**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