diff --git a/MODULE.md b/MODULE.md index 92ecc935..c3811987 100644 --- a/MODULE.md +++ b/MODULE.md @@ -11,7 +11,7 @@ RPC methods. Configuration requires `contract_version` and `policy`. The optiona ```json { - "contract_version": [1, 12], + "contract_version": [2, 0], "policy": { "enabled": false, "action_dir": "", @@ -385,6 +385,9 @@ returned by `ListToolTrust2` even when the tool's display label is redacted; the module verifies the row belongs to the supplied registered scope and records the usual audited cancellation. +Approval execution outcomes and the contract 2.0 compatibility boundary are +described in [`docs/specs/approval-execution-outcomes.md`](docs/specs/approval-execution-outcomes.md). + ### Scored prompt screening: `ScanPrompt2` `ScanPrompt2 { text }` answers `{ tier, score_milli, rule_ids }`. The tier is `allow`, `review` or diff --git a/README.md b/README.md index 77c288b4..16b79329 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,10 @@ Contract 1.25 adds `DevicePairing2`, which owns the paired-device static identity, ciphertext-only persistence, host-attested enrollment/reconnect, session refresh and revocation while preserving the existing v2/v3/v4 wire; see the [pairing lifecycle specification](docs/specs/device-pairing-contract-1.25.md). +Contract 2.0 adds the `abandoned` approval execution outcome for in-flight +executions whose effects could not be determined. Because `ExecutionOutcome` is +a closed wire enum, 1.x hosts reject 2.x modules rather than risk decoding a +result they cannot represent; see the [approval contract](docs/specs/approval-execution-outcomes.md). Contract 1.1 also supplies immutable native [filesystem scopes](docs/specs/immutable-path-scopes.md) through `RegisterPathPolicy`, `ValidatePath`, `ValidateParent` and `CheckPath`. Contract 1.3 adds native `Redact`, `ScanPrompt` and `ScanToolDefinition`, with a diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index e5a00092..f565fe22 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -429,6 +429,35 @@ fn action(row: &ApprovalRow2, phase: u64) -> ApprovalActionRequest2 { } } #[tokio::test] +async fn abandoned_execution_is_persisted_as_an_immutable_unknown_effect() -> Result<()> { + let f = Arc::new(Fixture::default()); + let owner = engine(f, Arc::new(TestClock::default()))?; + let row = owner.begin(request()).await?; + owner.decide(decision(&row)).await?; + owner.consume(action(&row, 3)).await?; + + let terminal = RecordExecutionRequest2 { + action: action(&row, 4), + outcome: ExecutionOutcome::Abandoned, + }; + let finished = owner.record_execution(terminal.clone()).await?; + assert_eq!(finished.state, ApprovalLifecycle2::Executed); + assert_eq!(finished.outcome, Some(ExecutionOutcome::Abandoned)); + assert_eq!( + owner.list(&request().scope_id).await?, + vec![finished.clone()] + ); + assert_eq!(owner.record_execution(terminal).await?, finished); + + let mut cancelled = RecordExecutionRequest2 { + action: action(&row, 4), + outcome: ExecutionOutcome::Cancelled, + }; + cancelled.action.audit = audit(5); + assert!(owner.record_execution(cancelled).await.is_err()); + Ok(()) +} +#[tokio::test] async fn exact_human_ticket_first_winner_once_consumption_and_terminal_outcome() -> Result<()> { let f = Arc::new(Fixture::default()); let owner = engine(f, Arc::new(TestClock::default()))?; diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index d75e7c83..79e24c34 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -300,7 +300,7 @@ impl ExecutionOutcome { match value { "success" => Ok(Self::Succeeded), "failure" => Ok(Self::Failed), - "aborted" => Ok(Self::Cancelled), + "aborted" => Ok(Self::Abandoned), _ => Err(crate::Error::InvalidArguments), } } diff --git a/crates/tinysecurity-bus/src/approval_tests.rs b/crates/tinysecurity-bus/src/approval_tests.rs index f4256b04..c230797b 100644 --- a/crates/tinysecurity-bus/src/approval_tests.rs +++ b/crates/tinysecurity-bus/src/approval_tests.rs @@ -11,7 +11,7 @@ fn legacy_execution_outcomes_are_explicit_and_unknown_values_reject() { ); assert_eq!( ExecutionOutcome::from_legacy("aborted"), - Ok(ExecutionOutcome::Cancelled) + Ok(ExecutionOutcome::Abandoned) ); assert!(ExecutionOutcome::from_legacy("approved").is_err()); } diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index ec64d356..3a1c0d57 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, 29)); + assert_eq!(crate::CONTRACT_VERSION, (2, 0)); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index ce8974fe..d1aeaea1 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, 29)); + assert_eq!(CONTRACT_VERSION, (2, 0)); } #[test] diff --git a/crates/tinysecurity-bus/src/future.rs b/crates/tinysecurity-bus/src/future.rs index 6de58fad..b42dda0d 100644 --- a/crates/tinysecurity-bus/src/future.rs +++ b/crates/tinysecurity-bus/src/future.rs @@ -20,7 +20,7 @@ pub enum ApprovalState { /// Executed state. Executed, } -/// `ExecutionOutcome` vocabulary, reserved until its engine is implemented. +/// Terminal execution result, including runs abandoned before their effects were known. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ExecutionOutcome { @@ -30,6 +30,8 @@ pub enum ExecutionOutcome { Failed, /// Cancelled state. Cancelled, + /// The in-flight execution was abandoned before its effects could be determined. + Abandoned, } /// `ReplyVerdict` vocabulary, reserved until its engine is implemented. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 633dab84..9b935175 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, 29); +pub const CONTRACT_VERSION: (u32, u32) = (2, 0); /// Whether a remote module's advertised version supplies this host's vocabulary. /// /// `version` is the provider version received by the host, not a host requirement diff --git a/crates/tinysecurity-bus/tests/fixtures/v1.json b/crates/tinysecurity-bus/tests/fixtures/v1.json index 17b1df30..159334a3 100644 --- a/crates/tinysecurity-bus/tests/fixtures/v1.json +++ b/crates/tinysecurity-bus/tests/fixtures/v1.json @@ -590,7 +590,8 @@ "ExecutionOutcome": [ "succeeded", "failed", - "cancelled" + "cancelled", + "abandoned" ], "ReplyVerdict": [ "allow_once", diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index a861de47..05a71266 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.29 compatibility boundary. +//! Registered subject policy wire shapes and the contract compatibility boundary. use serde_json::json; use tinysecurity_bus::*; @@ -87,11 +87,14 @@ 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, 29)); - // A host built against 1.29 accepts this module and newer minors, not a 1.28 module. - assert!(is_compatible((1, 29))); +fn incompatible_execution_outcome_requires_a_new_contract_major() { + assert_eq!(CONTRACT_VERSION, (2, 0)); + // A 2.0 host accepts compatible 2.x modules, but older 1.x hosts reject this module. + assert!(is_compatible((2, 0))); + assert!(is_compatible((2, 1))); + assert!(!is_compatible((1, 29))); assert!(!is_compatible((1, 28))); + assert!(!is_compatible((1, 27))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); assert!(!is_compatible((1, 24))); @@ -109,7 +112,6 @@ fn contract_bump_is_additive_for_older_hosts_and_modules() { // A 1.12 module cannot supply the audited scoped members. assert!(!is_compatible((1, 12))); assert!(!is_compatible((1, 11))); - assert!(!is_compatible((2, 0))); } #[test] diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index ac0a2e2f..ec594c44 100644 --- a/crates/tinysecurity-bus/tests/wire.rs +++ b/crates/tinysecurity-bus/tests/wire.rs @@ -114,6 +114,8 @@ fn all_execution_outcome_variants_have_stable_wire_names() assert_eq!(serde_json::to_value(decoded)?, *v); } assert!(serde_json::from_str::("\"future_unknown\"").is_err()); + let abandoned: ExecutionOutcome = serde_json::from_str("\"abandoned\"")?; + assert_eq!(serde_json::to_string(&abandoned)?, "\"abandoned\""); Ok(()) } #[test] @@ -343,7 +345,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, 29)); + assert_eq!(CONTRACT_VERSION, (2, 0)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs b/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs index ade2c8b9..a297cf7c 100644 --- a/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs +++ b/crates/tinysecurity-module/examples/secret_cipher_native_contract/fixture.rs @@ -2217,7 +2217,7 @@ pub(super) async fn run( let host = start_callbacks(&lifecycle, &state).await?; let config = configuration(&host, mode)?; let loaded = lifecycle.load_file_with_config(library, serde_json::to_value(&config)?); - if matches!(mode, 15 | 16) { + if matches!(mode, 14..=16) { reject_invalid_config(&lifecycle, &host, loaded).await?; task.abort(); return Ok(()); @@ -2228,6 +2228,9 @@ pub(super) async fn run( .await? .ok_or("native module peer")?; *state.module.lock().map_err(|_| denied())? = Some(module.clone()); + // Windows native callbacks can exceed the default five-second bus timeout + // while completing the approval and audit handshake. Keep the outer + // fixture deadline above the callbacks' serial budgets for every mode. let proxy = host .proxy(module.as_str(), names::OBJECT_PATH, names::INTERFACE)? // Let the module's own callback deadline settle pending work. In @@ -2341,11 +2344,14 @@ async fn execute( completion_audit: audit(*sequence + 2), }; *sequence += 3; - let response = proxy + // Execution can make three serial audit callbacks, each with the configured + // 10-second deadline. Keep the fixture's outer deadline above that budget. + let execute_proxy = proxy.clone().with_timeout(Duration::from_secs(35)); + let response = execute_proxy .call_confidential(names::methods::EXECUTE_SECRET_CIPHER2, (&execute,)) .await; assert!( - proxy + execute_proxy .call_confidential::( names::methods::EXECUTE_SECRET_CIPHER2, (&execute,) diff --git a/crates/tinysecurity-module/src/adapter_tests.rs b/crates/tinysecurity-module/src/adapter_tests.rs index df181651..cec9a611 100644 --- a/crates/tinysecurity-module/src/adapter_tests.rs +++ b/crates/tinysecurity-module/src/adapter_tests.rs @@ -1990,7 +1990,7 @@ async fn assert_invalid_sdk_reinitializations( setup( server.clone(), ModuleConfig { - contract_version: (2, 0), + contract_version: (3, 0), ..Default::default() } ) diff --git a/crates/tinysecurity-policy/src/engine_tests.rs b/crates/tinysecurity-policy/src/engine_tests.rs index 6f08d5d8..5b06b3ce 100644 --- a/crates/tinysecurity-policy/src/engine_tests.rs +++ b/crates/tinysecurity-policy/src/engine_tests.rs @@ -66,12 +66,12 @@ fn verdict(policy: &Policy, request: &EvaluateRequest) -> Result { } #[test] -fn additive_contract_minor_accepts_an_older_same_major_host() { +fn incompatible_contract_major_rejects_an_older_host() { let config = ModuleConfig { - contract_version: (1, 28), + contract_version: (1, 29), ..Default::default() }; - assert!(Policy::new(config).is_ok()); + assert!(Policy::new(config).is_err()); } #[test] diff --git a/crates/tinysecurity-policy/tests/policy.rs b/crates/tinysecurity-policy/tests/policy.rs index a1b6eb2e..c2b3ca6b 100644 --- a/crates/tinysecurity-policy/tests/policy.rs +++ b/crates/tinysecurity-policy/tests/policy.rs @@ -67,7 +67,7 @@ fn failed_reinit_preserves_old_generation_and_configuration() let mut policy = Policy::new(ModuleConfig::default())?; let old = policy.info(); let bad = ModuleConfig { - contract_version: (2, 0), + contract_version: (1, 29), ..Default::default() }; assert_eq!(policy.reinitialize(bad), Err(Error::IncompatibleVersion)); diff --git a/docs/specs/approval-execution-outcomes.md b/docs/specs/approval-execution-outcomes.md new file mode 100644 index 00000000..4ba42441 --- /dev/null +++ b/docs/specs/approval-execution-outcomes.md @@ -0,0 +1,12 @@ +# Approval execution outcomes + +Contract 2.0 adds `ExecutionOutcome::Abandoned` for an in-flight execution that +was abandoned before the host could determine its effects. It is persisted as +an immutable terminal outcome and does not claim that the tool had no effects. +Use `Cancelled` only when the host has a definite cancelled result. A host that +sees the legacy `aborted` value maps it to `Abandoned` because aborting a wait or +task does not prove that execution had no effects. + +This closed enum change is a contract-major bump: a 1.x host cannot safely +decode an `abandoned` result, so 1.x hosts reject a 2.x module during version +compatibility checks. diff --git a/docs/specs/security-module.md b/docs/specs/security-module.md index 57485334..f76a0e07 100644 --- a/docs/specs/security-module.md +++ b/docs/specs/security-module.md @@ -152,6 +152,14 @@ Default TTL is ten minutes; copilot and sub-agent TTL is three minutes. permission. Cron external effects deny. SaaS has no implicit bypass: configure a verified per-user prompt channel or deny when human approval is needed. +`ExecutionOutcome::Abandoned` records an in-flight execution that was abandoned +before the host could determine its effects. This is not a cancellation and does +not claim that the tool had no effects. Use `Cancelled` only for a definite +cancelled result; map legacy `aborted` values to `Abandoned` because aborting a +wait or task does not establish the execution's outcome. The `ExecutionOutcome` +wire enum is closed, so this value requires contract 2.0: 1.x hosts must reject +2.x providers rather than admit a provider whose result they cannot decode. + Jev is an optional judge, disabled by default and enabled per origin. It can only convert `RequireApproval` to `Allow`; it cannot override any `Deny`, floor, privacy or sandbox requirement. Redacted input and independent questions test