diff --git a/crates/tinysecurity-approval/README.md b/crates/tinysecurity-approval/README.md index fd76afdc..30c75e54 100644 --- a/crates/tinysecurity-approval/README.md +++ b/crates/tinysecurity-approval/README.md @@ -54,6 +54,33 @@ Being the trusted bus peer alone does not make model input a human action. Tickets are never persisted or printed by the module; only their SHA-256 correlation and sanitized attribution enter durable rows. +Forced, user-requested approval is a host routing mode: the host may start it +only from a validated WebChat request, must suppress all allowlists and +automatic decisions, and must allow only a once choice. A forced request from +any other origin is denied before `EvaluateApproval2`. Detached sub-agent +classification and keeping its approval card visible after the parent turn +ends also remain host responsibilities; the host must preserve the verified +origin when delegating and must not infer it from prompt or tool arguments. +The approval engine owns only the typed origin and durable decision. When a +parked request reaches its deadline, the host should call `ExpireApproval2` so +the row records `expired`; `CancelApproval2` is reserved for explicit host +cancellation. + +Rows carry optional `session_id`, copied from the already identifier-redacted +caller thread and checked against that binding when loaded. `ListRecentDecisions2` +reads terminal/approved decisions across currently registered scopes and omits +pending rows. `PurgeSession` receives that same hashed session identifier once +per registered scope; the host store removes only pending rows for the exact +scope/session pair and preserves decision history. The engine verifies each +scope/session echo and caps each callback's removal count. + +After an audit sink failure, the host may call `RecordAuditError2` with the raw +failure detail and a new audit correlation. The engine applies the configured +`RedactMode::Text` redactor, then caps the sanitized text to 512 characters, +and CAS-persists only `ApprovalRow2.audit_error`. This metadata update never +changes approval state or grants authority. The host must store/use the +returned sanitized row value and must never persist the raw callback argument. + ## Authority and lifecycle Version-one bindings use domain-separated SHA-256 of bounded original parsed diff --git a/crates/tinysecurity-approval/src/binding.rs b/crates/tinysecurity-approval/src/binding.rs index ba2479f3..28313714 100644 --- a/crates/tinysecurity-approval/src/binding.rs +++ b/crates/tinysecurity-approval/src/binding.rs @@ -244,6 +244,18 @@ pub(crate) fn parse(record: &ApprovalRecord2, scope: &SubjectScope) -> Result 1024) + || row + .audit_error + .as_ref() + .is_some_and(|error| error.chars().count() > 512) || !hex(&row.request_id, 32) || !hex(&row.host_instance, 32) || !hex(&row.nonce, 32) diff --git a/crates/tinysecurity-approval/src/engine.rs b/crates/tinysecurity-approval/src/engine.rs index bde21352..ea2a6222 100644 --- a/crates/tinysecurity-approval/src/engine.rs +++ b/crates/tinysecurity-approval/src/engine.rs @@ -9,12 +9,12 @@ use tinysecurity_bus::{ ApprovalActionRequest2, ApprovalCasRequest2, ApprovalCasResponse2, ApprovalChoice2, ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalGrants2, ApprovalHistoryImport2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, - ApprovalRecordRequest2, ApprovalRow2, ApprovalScope2, ApprovalScopeOutcome2, - ApprovalScopeReceipt2, AuditCorrelation2, CallerContext, Check, DecideRequest2, - EvaluateApprovalRequest2, EvaluateRequest, HUMAN_ONLY_TOOLS2, HumanDecisionReceipt2, - MAX_APPROVAL_SCOPES2, Origin, PreauthorizeFlowRequest2, RecordExecutionRequest2, RedactMode, - RegisterApprovalScopeRequest2, RetireApprovalScopeRequest2, SubjectScope, ToolCall, - VerifyHumanRequest2, + ApprovalPurgeSessionRequest2, ApprovalRecordRequest2, ApprovalRow2, ApprovalScope2, + ApprovalScopeOutcome2, ApprovalScopeReceipt2, AuditCorrelation2, CallerContext, Check, + DecideRequest2, EvaluateApprovalRequest2, EvaluateRequest, HUMAN_ONLY_TOOLS2, + HumanDecisionReceipt2, MAX_APPROVAL_SCOPES2, Origin, PreauthorizeFlowRequest2, + RecordExecutionRequest2, RedactMode, RegisterApprovalScopeRequest2, + RetireApprovalScopeRequest2, SubjectScope, ToolCall, VerifyHumanRequest2, }; use tinysecurity_redact::Redactor; /// Trusted acknowledgement binding an exact row to the untouched actual sink receipt. @@ -122,6 +122,15 @@ pub trait Ports: Send + Sync + std::fmt::Debug { /// # Errors /// Timeout is indeterminate; conflicting mutation reuse must reject. async fn cas(&self, request: ApprovalCasRequest2) -> Result; + /// Remove undecided rows for one exact session in one exact subject. + /// # Errors + /// Rejects an unavailable store or a response that does not echo the request. + async fn purge_session( + &self, + _request: ApprovalPurgeSessionRequest2, + ) -> Result { + Err(Error::Denied) + } /// Deliver a verified per-user prompt; never a decision. /// # Errors /// Rejects unavailable or unverified human channels. @@ -255,6 +264,9 @@ impl ScopeEntry { durable_flow: bool, durable_tool: bool, ) -> Option<&'static str> { + if self.scope.require_approval && !durable_tool && !durable_flow { + return None; + } match rule { tinysecurity_auto::rules::Rule::Approval => self .grant( @@ -532,6 +544,7 @@ impl Engine { if expiry <= now { return Err(Error::Denied); } + let session_id = bound.caller.thread.clone(); let row = ApprovalRow2 { request_id: binding::opaque()?, version: 1, @@ -539,6 +552,8 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: self.begin_tool_label(&request.request.call.tool, deadline.is_some())?, + session_id, + audit_error: None, external_effect, args_redacted, summary: binding::text(&self.redactor()?, &request.summary, RedactMode::Text)?, @@ -1052,6 +1067,8 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: "imported-history".into(), + session_id: None, + audit_error: None, external_effect: false, args_redacted: "{}".into(), summary: "imported-history".into(), @@ -1675,7 +1692,7 @@ impl Engine { let entry = self.entry(&request.scope_id)?; // An unlabelled call is approved by a whole-tool grant alone; a flow grant // added since never keeps its approval alive. - let mut granted = if row.binding.origin == Origin::Unlabelled { + let mut granted = if matches!(row.binding.origin, Origin::Unlabelled | Origin::Unknown) { entry.tool_grant(&request.request.call.tool).is_some() } else { entry @@ -1794,6 +1811,41 @@ impl Engine { self.persist(Some(previous), &row).await?; Ok(row) } + /// Persist only a redacted diagnostic after an audit sink failure. + /// This metadata mutation never changes the approval lifecycle or grants authority. + /// # Errors + /// Rejects invalid diagnostics, stale rows, mismatched scopes or unavailable storage. + pub async fn record_audit_error( + &self, + request: tinysecurity_bus::RecordAuditErrorRequest2, + ) -> Result { + if request.audit_error.is_empty() || request.audit_error.len() > 8192 { + return Err(Error::Invalid); + } + let reg = self.registration(&request.scope_id)?; + let mut row = self.row(®, &request.request_id).await?; + Self::current(®, &row)?; + let sanitized = binding::text(&self.redactor()?, &request.audit_error, RedactMode::Text)?; + let sanitized = if sanitized.chars().count() > 512 { + let head: String = sanitized.chars().take(511).collect(); + format!("{head}…") + } else { + sanitized + }; + Self::correlation(&request.audit, reg.generation)?; + if row.audit_error.as_deref() == Some(&sanitized) && row.audit == request.audit { + return Ok(row); + } + if row.audit_error.is_some() { + return Err(Error::Conflict); + } + let previous = row.version; + row.version = previous.checked_add(1).ok_or(Error::Invalid)?; + row.audit_error = Some(sanitized); + row.audit = request.audit; + self.persist(Some(previous), &row).await?; + Ok(row) + } fn current(reg: &ApprovalScope2, row: &ApprovalRow2) -> Result<()> { if row.binding.generation != reg.generation || row.binding.scope != binding::scope_handle(®.subject)? @@ -1854,6 +1906,71 @@ impl Engine { pub async fn list(&self, scope_id: &str) -> Result> { self.rows(&self.registration(scope_id)?).await } + /// Read the newest durable decisions across currently registered scopes. + /// Pending requests are excluded; the result is bounded to 1..=500 rows. + /// # Errors + /// Rejects malformed rows or unavailable scope stores. + pub async fn list_recent_decisions(&self, limit: usize) -> Result> { + let scopes = self + .scopes + .read() + .map_err(|_| Error::Unavailable)? + .by_id + .values() + .map(|entry| entry.scope.clone()) + .collect::>(); + let mut rows = Vec::new(); + for scope in scopes { + rows.extend( + self.rows(&scope) + .await? + .into_iter() + .filter(|row| row.state != ApprovalLifecycle2::Pending), + ); + } + rows.sort_by(|left, right| { + right + .created_at_ms + .cmp(&left.created_at_ms) + .then_with(|| left.request_id.cmp(&right.request_id)) + }); + rows.truncate(limit.clamp(1, 500)); + Ok(rows) + } + /// Remove undecided rows belonging to one session across registered subjects. + /// The session identifier is redacted once and matched against stored row hashes. + /// Decided rows remain as durable audit history. + /// # Errors + /// Rejects invalid identifiers, malformed responses or unavailable stores. + pub async fn purge_session(&self, session_id: &str) -> Result { + if session_id.trim().is_empty() || session_id.len() > 1024 { + return Err(Error::Invalid); + } + let session_id = binding::text(&self.redactor()?, session_id, RedactMode::IdentifierHash)?; + let scopes = self + .scopes + .read() + .map_err(|_| Error::Unavailable)? + .by_id + .values() + .map(|entry| entry.scope.clone()) + .collect::>(); + let mut removed = 0_usize; + for scope in scopes { + let request = ApprovalPurgeSessionRequest2 { + scope: binding::scope_handle(&scope.subject)?, + session_id: session_id.clone(), + }; + let response = self.ports.purge_session(request.clone()).await?; + request + .verify_response(&response) + .map_err(|_| Error::Invalid)?; + removed = removed + .checked_add(response.removed as usize) + .ok_or(Error::Invalid)?; + } + Ok(removed) + } /// Resolve pending or approved state as cancelled, retaining its captured scope. /// # Errors /// Rejects already terminal, unrelated or unacknowledged transitions. diff --git a/crates/tinysecurity-approval/src/engine_flow_trust.rs b/crates/tinysecurity-approval/src/engine_flow_trust.rs index b688a440..74ba8c97 100644 --- a/crates/tinysecurity-approval/src/engine_flow_trust.rs +++ b/crates/tinysecurity-approval/src/engine_flow_trust.rs @@ -166,6 +166,8 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, + session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "flow trust".into(), diff --git a/crates/tinysecurity-approval/src/engine_gaps_tests.rs b/crates/tinysecurity-approval/src/engine_gaps_tests.rs index aeaca20c..b793c448 100644 --- a/crates/tinysecurity-approval/src/engine_gaps_tests.rs +++ b/crates/tinysecurity-approval/src/engine_gaps_tests.rs @@ -202,10 +202,230 @@ async fn unlabelled_origin_is_denied_without_a_whole_tool_grant_and_leaves_nothi Ok(()) } +#[tokio::test] +async fn unknown_origin_denies_without_a_whole_tool_grant() -> Result<()> { + let (fixture, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Unknown, "unknown-origin"); + request.audit = at(1); + assert_eq!(owner.begin(request).await.err(), Some(Error::Denied)); + assert_eq!(prompts(&fixture)?, 0); + Ok(()) +} + +#[tokio::test] +async fn recent_decisions_include_all_registered_scopes_and_skip_pending_rows() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.register_scope(register(8)).await?; + let mut first = shell(7, None, Origin::TrustedAutomation, "decision-1"); + first.audit = at(1); + let first_row = owner.begin(first).await?; + let mut pending = shell(7, None, Origin::Operator, "pending"); + pending.audit = at(2); + let pending_row = owner.begin(pending).await?; + let mut second = shell(8, None, Origin::TrustedAutomation, "decision-2"); + second.audit = at(3); + let second_row = owner.begin(second).await?; + + let rows = owner.list_recent_decisions(10).await?; + assert_eq!(rows.len(), 2); + assert!( + rows.iter() + .all(|row| row.state == ApprovalLifecycle2::Approved) + ); + assert!( + rows.iter() + .any(|row| row.request_id == first_row.request_id) + ); + assert!( + rows.iter() + .any(|row| row.request_id == second_row.request_id) + ); + assert!( + !rows + .iter() + .any(|row| row.request_id == pending_row.request_id) + ); + Ok(()) +} + +#[tokio::test] +async fn session_purge_matches_one_redacted_id_across_scopes_and_keeps_decisions() -> Result<()> { + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + owner.register_scope(register(8)).await?; + let mut pending_a = shell(7, None, Origin::Operator, "pending-a"); + pending_a.request.caller.thread = Some("session-A".into()); + pending_a.audit = at(1); + let pending_a = owner.begin(pending_a).await?; + let mut pending_b = shell(8, None, Origin::Operator, "pending-b"); + pending_b.request.caller.thread = Some("session-A".into()); + pending_b.audit = at(2); + owner.begin(pending_b).await?; + let mut other_session = shell(7, None, Origin::Operator, "other-session"); + other_session.request.caller.thread = Some("session-B".into()); + other_session.audit = at(3); + let other_session = owner.begin(other_session).await?; + let mut decided = shell(8, None, Origin::TrustedAutomation, "decided"); + decided.request.caller.thread = Some("session-A".into()); + decided.audit = at(4); + let decided = owner.begin(decided).await?; + + assert_eq!(owner.purge_session("session-A").await?, 2); + assert_eq!(owner.purge_session("").await.err(), Some(Error::Invalid)); + let first_scope = owner.list(&id(7)).await?; + let second_scope = owner.list(&id(8)).await?; + assert!( + !first_scope + .iter() + .any(|row| row.request_id == pending_a.request_id) + ); + assert!( + first_scope + .iter() + .any(|row| row.request_id == other_session.request_id) + ); + assert!( + second_scope + .iter() + .any(|row| row.request_id == decided.request_id) + ); + Ok(()) +} + +#[tokio::test] +async fn audit_error_is_redacted_before_persistence_and_capped_after_redaction() -> Result<()> { + let fixture = Arc::new(Fixture::default()); + let redactor_secret = "private-audit-secret"; + let mut redactor_config = RedactorConfig::default(); + redactor_config.secrets.push(Secret(redactor_secret.into())); + let owner = Engine::new( + config(), + fixture.clone(), + Arc::new(TestClock::default()), + Redactor::new(&redactor_config).map_err(|_| Error::Invalid)?, + )?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Operator, "audit-error"); + request.audit = at(1); + let row = owner.begin(request).await?; + let raw = format!("sink rejected {redactor_secret} {}", "x".repeat(700)); + + let recorded = owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id.clone(), + audit_error: raw.clone(), + audit: at(2), + }) + .await?; + + let value = recorded.audit_error.as_deref().ok_or(Error::Invalid)?; + assert!(value.chars().count() <= 512); + assert!(!value.contains(redactor_secret)); + assert!(!value.contains(&"x".repeat(700))); + assert_eq!(recorded.state, ApprovalLifecycle2::Pending); + assert!( + fixture + .rows + .lock() + .map_err(|_| Error::Unavailable)? + .contains_key(&row.request_id) + ); + Ok(()) +} + +#[tokio::test] +async fn audit_error_short_value_is_idempotent_and_conflicting_updates_are_rejected() -> Result<()> +{ + let (_, owner) = owner()?; + owner.register_scope(register(7)).await?; + let mut request = shell(7, None, Origin::Operator, "audit-error-short"); + request.audit = at(1); + let row = owner.begin(request).await?; + let input = tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id.clone(), + audit_error: "sink unavailable".into(), + audit: at(2), + }; + let recorded = owner.record_audit_error(input.clone()).await?; + assert_eq!(recorded.audit_error.as_deref(), Some("sink unavailable")); + assert_eq!(owner.record_audit_error(input).await?, recorded); + assert_eq!( + owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: row.request_id, + audit_error: "different failure".into(), + audit: at(3), + }) + .await + .err(), + Some(Error::Conflict) + ); + Ok(()) +} + +#[tokio::test] +async fn audit_error_rejects_empty_diagnostic_before_loading_a_row() -> Result<()> { + let (_, owner) = owner()?; + let result = owner + .record_audit_error(tinysecurity_bus::RecordAuditErrorRequest2 { + scope_id: id(7), + request_id: "missing".into(), + audit_error: String::new(), + audit: at(1), + }) + .await; + assert_eq!(result.err(), Some(Error::Invalid)); + Ok(()) +} + +#[tokio::test] +async fn default_session_purge_port_fails_closed() -> Result<()> { + #[derive(Debug)] + struct DefaultPurgePort; + #[async_trait::async_trait] + impl Ports for DefaultPurgePort { + async fn load(&self, _: ApprovalLoadRequest2) -> Result { + Err(Error::Unavailable) + } + async fn cas(&self, _: ApprovalCasRequest2) -> Result { + Err(Error::Unavailable) + } + async fn prompt(&self, _: ApprovalDeliveryRequest2) -> Result { + Err(Error::Unavailable) + } + async fn human(&self, _: VerifyHumanRequest2) -> Result { + Err(Error::Unavailable) + } + async fn audit(&self, _: ApprovalRow2) -> Result<()> { + Err(Error::Unavailable) + } + } + let result = Ports::purge_session( + &DefaultPurgePort, + tinysecurity_bus::ApprovalPurgeSessionRequest2 { + scope: tinysecurity_bus::SubjectScope { + user: "user".into(), + workspace: "workspace".into(), + agent: "agent".into(), + }, + session_id: "redacted-session".into(), + }, + ) + .await; + assert_eq!(result.err(), Some(Error::Denied)); + Ok(()) +} + #[tokio::test] async fn unlabelled_origin_is_approved_only_by_the_exact_whole_tool_grant() -> Result<()> { let (fixture, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req).await?; let row = owner @@ -909,6 +1129,23 @@ async fn whole_tool_trust_keeps_cron_unlabelled_and_human_only_boundaries() -> R Ok(()) } +#[tokio::test] +async fn require_approval_overrides_whole_tool_allowlist() -> Result<()> { + let (_, owner) = owner()?; + let mut scope = register(7); + scope.scope.require_approval = true; + scope.grants = grants(&["shell"], &[]); + owner.register_scope(scope).await?; + + let mut request = shell(7, None, Origin::Operator, "required-call"); + request.audit = at(2); + let row = owner.begin(request).await?; + + assert_eq!(row.state, ApprovalLifecycle2::Pending); + assert_eq!(row.attribution, None); + Ok(()) +} + #[tokio::test] async fn whole_tool_grant_fails_closed_when_the_store_cannot_persist() -> Result<()> { let (fixture, owner) = owner()?; @@ -1258,6 +1495,7 @@ async fn source_import_validation_and_store_errors_fail_closed() -> Result<()> { async fn an_unlabelled_approval_dies_when_only_a_flow_grant_remains() -> Result<()> { let (_, owner) = owner()?; let mut req = register(7); + req.scope.require_approval = false; req.grants = grants(&["shell"], &[]); owner.register_scope(req.clone()).await?; let call = shell(7, Some("flow-1"), Origin::Unlabelled, "call-1"); diff --git a/crates/tinysecurity-approval/src/engine_import_trust.rs b/crates/tinysecurity-approval/src/engine_import_trust.rs index 05cf2805..f2cd4670 100644 --- a/crates/tinysecurity-approval/src/engine_import_trust.rs +++ b/crates/tinysecurity-approval/src/engine_import_trust.rs @@ -157,6 +157,8 @@ fn imported_row( host_instance: engine.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &plan.tool, RedactMode::Text)?, + session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "imported trust source item".into(), diff --git a/crates/tinysecurity-approval/src/engine_scopes_tests.rs b/crates/tinysecurity-approval/src/engine_scopes_tests.rs index 64c6a6e1..dcd3fff6 100644 --- a/crates/tinysecurity-approval/src/engine_scopes_tests.rs +++ b/crates/tinysecurity-approval/src/engine_scopes_tests.rs @@ -1,4 +1,5 @@ use super::*; +use tinysecurity_bus::GrantToolRequest2; const STREAM: &str = "5555555555555555555555555555555a"; @@ -363,9 +364,8 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re let row = owner .begin(begin_for(7, "shell", None, Origin::Operator)) .await?; - assert_eq!(row.state, ApprovalLifecycle2::Approved); - assert_eq!(row.attribution.as_deref(), Some("auto:tool-grant")); - assert_eq!(row.choice, Some(ApprovalChoice2::Once)); + assert_eq!(row.state, ApprovalLifecycle2::Pending); + assert_eq!(row.attribution, None); assert!(row.decision_ticket_sha256.is_none()); assert!( fixture @@ -376,7 +376,7 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re .any(|a| a.request_id == row.request_id), "a granted row is audited" ); - // The row survives a restart: the same call resolves to it and reserves once. + // A configured allowlist cannot shortcut the required human decision. assert_eq!( owner .begin(begin_for(7, "shell", None, Origin::Operator)) @@ -384,13 +384,6 @@ async fn tool_grant_approves_only_that_tool_with_a_persisted_audited_row() -> Re .request_id, row.request_id ); - let mut act = action(&row, 3); - act.scope_id = id(7); - act.request = begin_for(7, "shell", None, Origin::Operator).request; - assert_eq!( - owner.consume(act).await?.state, - ApprovalLifecycle2::Reserved - ); let other = owner .begin(begin_for(7, "http_request", None, Origin::Operator)) .await?; @@ -407,8 +400,8 @@ async fn flow_grant_is_scoped_to_that_flow_and_tool() -> Result<()> { let granted = owner .begin(begin_for(7, "shell", Some("flow-1"), Origin::Operator)) .await?; - assert_eq!(granted.state, ApprovalLifecycle2::Approved); - assert_eq!(granted.attribution.as_deref(), Some("auto:flow-grant")); + assert_eq!(granted.state, ApprovalLifecycle2::Pending); + assert_eq!(granted.attribution, None); for (tool, flow) in [ ("shell", Some("flow-2")), ("shell", None), @@ -432,7 +425,7 @@ async fn grants_reach_saas_but_never_override_cron_external_denial_or_human_only let saas = owner .begin(begin_for(7, "shell", None, Origin::Saas)) .await?; - assert_eq!(saas.state, ApprovalLifecycle2::Approved); + assert_eq!(saas.state, ApprovalLifecycle2::Pending); let mut cron = begin_for(7, "shell", None, Origin::Cron); cron.request.call.checks = vec![Check::Command { command: "x".into(), @@ -808,7 +801,7 @@ async fn revoking_a_grant_blocks_consumption_of_its_unconsumed_approval() -> Res owner.register_scope(req).await?; let call = begin_for(7, "shell", None, Origin::Operator); let row = owner.begin(call.clone()).await?; - assert_eq!(row.state, ApprovalLifecycle2::Approved); + assert_eq!(row.state, ApprovalLifecycle2::Pending); // The grant is removed before the approved row is consumed. owner.replace_scope(register(7)).await?; let mut act = action(&row, 3); @@ -838,10 +831,10 @@ async fn grant_revoked_while_the_ports_await_never_publishes_an_approval() -> Re owner.replace_scope(register(7)).await?; fixture.release_load.notify_one(); assert_eq!( - racing.await.map_err(|_| Error::Unavailable)?.err(), - Some(Error::Denied) + racing.await.map_err(|_| Error::Unavailable)??.state, + ApprovalLifecycle2::Pending ); - // Asking again under the replaced scope needs a human. + // The configured allowlist never bypasses the human decision. let again = owner .begin(begin_for(7, "shell", None, Origin::Operator)) .await?; @@ -963,13 +956,18 @@ async fn policy_replaced_while_consume_awaits_never_reserves_the_old_approval() async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result<()> { let (fixture, owner) = owner()?; let owner = Arc::new(owner); - let mut req = register(7); - req.grants = grants(&["shell"], &[]); - owner.register_scope(req).await?; + owner.register_scope(register(7)).await?; + owner + .grant_tool(GrantToolRequest2 { + scope_id: id(7), + tool: "shell".into(), + audit: audit(1), + }) + .await?; let call = begin_for(7, "shell", None, Origin::Operator); let row = owner.begin(call.clone()).await?; assert_eq!(row.state, ApprovalLifecycle2::Approved); - // The grant disappears after the checks but before the write commits. + // The trusted grant disappears after checks but before the write commits. let before = fixture.cas_calls.load(Ordering::SeqCst); fixture .barrier @@ -990,7 +988,11 @@ async fn scope_replacement_waits_for_a_trusted_reservation_to_commit() -> Result } let replacing = tokio::spawn({ let owner = owner.clone(); - async move { owner.replace_scope(register(7)).await } + async move { + let mut next = register(7); + next.scope.policy_ref = "replacement-policy".into(); + owner.replace_scope(next).await + } }); tokio::task::yield_now().await; assert!(!replacing.is_finished()); @@ -1060,7 +1062,7 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu .await } }); - // The new row's write is parked: the grant is revoked after the early checks. + // The new row's write is parked while the configured allowlist is replaced. while fixture.cas_calls.load(Ordering::SeqCst) == before { tokio::task::yield_now().await; } @@ -1073,8 +1075,8 @@ async fn grant_revoked_while_the_approval_is_published_is_not_returned() -> Resu .ok_or(Error::Unavailable)?; barrier.wait().await; assert_eq!( - racing.await.map_err(|_| Error::Unavailable)?.err(), - Some(Error::Denied) + racing.await.map_err(|_| Error::Unavailable)??.state, + ApprovalLifecycle2::Pending ); Ok(()) } diff --git a/crates/tinysecurity-approval/src/engine_tests.rs b/crates/tinysecurity-approval/src/engine_tests.rs index e5a00092..68253e9d 100644 --- a/crates/tinysecurity-approval/src/engine_tests.rs +++ b/crates/tinysecurity-approval/src/engine_tests.rs @@ -107,6 +107,33 @@ impl Ports for Fixture { }; NativeAuditAcknowledgement::from_verified_sink(row, expected_scope, receipt) } + async fn purge_session( + &self, + request: tinysecurity_bus::ApprovalPurgeSessionRequest2, + ) -> Result { + let mut rows = self.rows.lock().map_err(|_| Error::Unavailable)?; + let remove = rows + .iter() + .filter_map(|(id, record)| { + serde_json::from_str::(&record.record_json) + .ok() + .filter(|row| { + row.binding.scope == request.scope + && row.session_id.as_deref() == Some(&request.session_id) + && row.state == ApprovalLifecycle2::Pending + }) + .map(|_| id.clone()) + }) + .collect::>(); + for id in &remove { + rows.remove(id); + } + Ok(tinysecurity_bus::ApprovalPurgeSessionResponse2 { + scope: request.scope, + session_id: request.session_id, + removed: u32::try_from(remove.len()).map_err(|_| Error::Invalid)?, + }) + } async fn load(&self, r: ApprovalLoadRequest2) -> Result { if self.load_fail.load(Ordering::SeqCst) != 0 { return Err(Error::Unavailable); diff --git a/crates/tinysecurity-approval/src/engine_tool_trust.rs b/crates/tinysecurity-approval/src/engine_tool_trust.rs index f0806db9..f2bef415 100644 --- a/crates/tinysecurity-approval/src/engine_tool_trust.rs +++ b/crates/tinysecurity-approval/src/engine_tool_trust.rs @@ -152,6 +152,8 @@ impl Engine { host_instance: self.instance.clone(), nonce: binding::opaque()?, tool_name: binding::text(&redactor, &request.tool, RedactMode::Text)?, + session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "whole-tool trust".into(), diff --git a/crates/tinysecurity-auto/src/rules.rs b/crates/tinysecurity-auto/src/rules.rs index ba1f528a..bd349001 100644 --- a/crates/tinysecurity-auto/src/rules.rs +++ b/crates/tinysecurity-auto/src/rules.rs @@ -22,7 +22,7 @@ pub fn evaluate(scope: &ApprovalScope2, origin: &Origin, external: bool) -> Rule match origin { // The host's own pre-declared automation and a local operator never park. Origin::TrustedAutomation | Origin::LocalOperator => return Rule::Automatic, - Origin::Unlabelled => return Rule::GrantOnly, + Origin::Unlabelled | Origin::Unknown => return Rule::GrantOnly, _ => {} } if *origin != Origin::Saas diff --git a/crates/tinysecurity-auto/src/rules_tests.rs b/crates/tinysecurity-auto/src/rules_tests.rs index dfaebd44..97940fce 100644 --- a/crates/tinysecurity-auto/src/rules_tests.rs +++ b/crates/tinysecurity-auto/src/rules_tests.rs @@ -16,6 +16,7 @@ fn cron_external_denies_before_shortcuts_and_saas_has_no_implicit_permission() { auto_approve_all: false, }; assert_eq!(evaluate(&scope, &Origin::Operator, false), Rule::Approval); + assert_eq!(evaluate(&scope, &Origin::Unknown, false), Rule::GrantOnly); for automatic in [false, true] { scope.auto_approve_all = automatic; for required in [false, true] { @@ -62,6 +63,10 @@ fn trusted_automation_and_local_operator_are_automatic_whatever_the_posture() { evaluate(&scope, &Origin::Unlabelled, external), Rule::GrantOnly ); + assert_eq!( + evaluate(&scope, &Origin::Unknown, external), + Rule::GrantOnly + ); } } } diff --git a/crates/tinysecurity-bus/src/approval.rs b/crates/tinysecurity-bus/src/approval.rs index d75e7c83..21825c16 100644 --- a/crates/tinysecurity-bus/src/approval.rs +++ b/crates/tinysecurity-bus/src/approval.rs @@ -123,6 +123,12 @@ pub struct ApprovalRow2 { pub nonce: String, /// Sanitized tool name. pub tool_name: String, + /// Session correlation copied from the trusted caller context, when present. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub session_id: Option, + /// Redacted audit sink failure detail, capped at 512 characters. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_error: Option, /// Concrete effect class derived from actual checks. pub external_effect: bool, /// Redacted canonical original argument JSON. @@ -229,6 +235,19 @@ pub struct RecordExecutionRequest2 { /// Immutable terminal outcome. pub outcome: ExecutionOutcome, } +/// Record a sanitized diagnostic after the host's audit sink failed. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecordAuditErrorRequest2 { + /// Trusted registration. + pub scope_id: String, + /// Exact durable approval row. + pub request_id: String, + /// Raw sink error; the engine redacts and caps it before persistence. + pub audit_error: String, + /// Exact next-version audit correlation for the metadata CAS. + pub audit: AuditCorrelation2, +} /// Scoped listing, expiry or cancellation request; no ambient subject lookup. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -240,6 +259,25 @@ pub struct ApprovalRecordRequest2 { /// Audit correlation for lifecycle changes. pub audit: AuditCorrelation2, } +/// Bounded cross-scope recent-decision read. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct RecentApprovalDecisionsRequest2 { + /// Maximum number of rows, clamped by the engine to 1..=500. + pub limit: usize, +} +/// Cross-scope request to remove undecided rows for one session. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PurgeApprovalSessionRequest2 { + /// Raw host session identity, redacted once by the engine before store calls. + pub session_id: String, +} +impl std::fmt::Debug for PurgeApprovalSessionRequest2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("PurgeApprovalSessionRequest2([REDACTED])") + } +} /// Bounded flow review referencing already human-reviewed exact rows. #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] @@ -264,6 +302,7 @@ redacted_debug!( HumanDecisionReceipt2, ApprovalActionRequest2, RecordExecutionRequest2, + RecordAuditErrorRequest2, ApprovalRecordRequest2, PreauthorizeFlowRequest2 ); diff --git a/crates/tinysecurity-bus/src/approval_tests.rs b/crates/tinysecurity-bus/src/approval_tests.rs index f4256b04..14c12ff2 100644 --- a/crates/tinysecurity-bus/src/approval_tests.rs +++ b/crates/tinysecurity-bus/src/approval_tests.rs @@ -38,3 +38,15 @@ fn human_ticket_wire_is_strict_and_debug_never_discloses_opaque_authority() assert_eq!(format!("{request:?}"), "DecideRequest2([REDACTED])"); Ok(()) } + +#[test] +fn purge_session_debug_redacts_the_raw_session_identity() { + let request = PurgeApprovalSessionRequest2 { + session_id: "private-session-id".into(), + }; + + assert_eq!( + format!("{request:?}"), + "PurgeApprovalSessionRequest2([REDACTED])" + ); +} diff --git a/crates/tinysecurity-bus/src/callbacks.rs b/crates/tinysecurity-bus/src/callbacks.rs index 4dd1b8d8..9d7eb715 100644 --- a/crates/tinysecurity-bus/src/callbacks.rs +++ b/crates/tinysecurity-bus/src/callbacks.rs @@ -22,6 +22,54 @@ pub struct ApprovalLoadResponse2 { /// Scoped sanitized rows, uniquely identified within this snapshot. pub records: Vec, } +/// Delete undecided approval rows for one redacted session within one subject. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPurgeSessionRequest2 { + /// Sanitized durable scope, mapped by the verified trusted host adapter. + pub scope: SubjectScope, + /// Session identifier after the configured identifier redactor. + pub session_id: String, +} +impl std::fmt::Debug for ApprovalPurgeSessionRequest2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApprovalPurgeSessionRequest2([REDACTED])") + } +} +/// Exact response to an undecided-row session purge. +#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ApprovalPurgeSessionResponse2 { + /// Exact requested durable scope. + pub scope: SubjectScope, + /// Exact requested redacted session identifier. + pub session_id: String, + /// Number of undecided rows removed, at most the bounded store inventory. + pub removed: u32, +} +impl std::fmt::Debug for ApprovalPurgeSessionResponse2 { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("ApprovalPurgeSessionResponse2([REDACTED])") + } +} +impl ApprovalPurgeSessionRequest2 { + /// Verify that a purge response matches this exact scope and session. + /// + /// # Errors + /// Rejects malformed identifiers, mismatched echoes or an impossible count. + pub fn verify_response(&self, response: &ApprovalPurgeSessionResponse2) -> crate::Result<()> { + validate_scope(&self.scope)?; + if self.session_id.is_empty() + || self.session_id.len() > 1024 + || self.scope != response.scope + || self.session_id != response.session_id + || response.removed > 1024 + { + return Err(crate::Error::InvalidArguments); + } + Ok(()) + } +} impl std::fmt::Debug for ApprovalLoadResponse2 { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("ApprovalLoadResponse2([REDACTED])") @@ -169,6 +217,8 @@ pub mod callback_names2 { pub const APPEND: &str = "Append"; /// Atomic storage mutation member. pub const CAS: &str = "Cas"; + /// Remove only undecided approval rows bound to the exact session. + pub const PURGE_SESSION: &str = "PurgeSession"; /// Scoped read member. pub const LOAD: &str = "Load"; /// Deliver a real per-user prompt; delivery alone is never authority. diff --git a/crates/tinysecurity-bus/src/callbacks_tests.rs b/crates/tinysecurity-bus/src/callbacks_tests.rs index 4190c08d..a1808e68 100644 --- a/crates/tinysecurity-bus/src/callbacks_tests.rs +++ b/crates/tinysecurity-bus/src/callbacks_tests.rs @@ -1,4 +1,54 @@ use super::*; +#[test] +fn session_purge_contract_roundtrips_and_rejects_unrelated_responses() +-> std::result::Result<(), Box> { + let request = ApprovalPurgeSessionRequest2 { + scope: SubjectScope { + user: "user-hash".into(), + workspace: "workspace-hash".into(), + agent: "agent-hash".into(), + }, + session_id: "session-hash".into(), + }; + let encoded = serde_json::to_string(&request)?; + let decoded: ApprovalPurgeSessionRequest2 = serde_json::from_str(&encoded)?; + assert_eq!(decoded, request); + let response = ApprovalPurgeSessionResponse2 { + scope: request.scope.clone(), + session_id: request.session_id.clone(), + removed: 3, + }; + assert_eq!(request.verify_response(&response), Ok(())); + let mut unrelated = response; + unrelated.session_id = "other-session".into(); + assert_eq!( + request.verify_response(&unrelated), + Err(crate::Error::InvalidArguments) + ); + Ok(()) +} + +#[test] +fn session_purge_debug_redacts_the_session_identifier() { + let request = ApprovalPurgeSessionRequest2 { + scope: SubjectScope { + user: "user-hash".into(), + workspace: "workspace-hash".into(), + agent: "agent-hash".into(), + }, + session_id: "private-session-hash".into(), + }; + let response = ApprovalPurgeSessionResponse2 { + scope: request.scope.clone(), + session_id: request.session_id.clone(), + removed: 1, + }; + let debug = format!("{request:?}{response:?}"); + assert!(debug.contains("ApprovalPurgeSessionRequest2([REDACTED])")); + assert!(debug.contains("ApprovalPurgeSessionResponse2([REDACTED])")); + assert!(!debug.contains("private-session-hash")); +} + fn mutation() -> ApprovalCasRequest2 { ApprovalCasRequest2 { scope: SubjectScope { diff --git a/crates/tinysecurity-bus/src/device_frame_tests.rs b/crates/tinysecurity-bus/src/device_frame_tests.rs index ec64d356..0965607e 100644 --- a/crates/tinysecurity-bus/src/device_frame_tests.rs +++ b/crates/tinysecurity-bus/src/device_frame_tests.rs @@ -70,5 +70,9 @@ 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, (1, 30)); + assert!(crate::is_compatible((1, 30))); + assert!(crate::is_compatible((1, 31))); + assert!(!crate::is_compatible((1, 29))); + assert!(!crate::is_compatible((2, 30))); } diff --git a/crates/tinysecurity-bus/src/device_pairing_tests.rs b/crates/tinysecurity-bus/src/device_pairing_tests.rs index ce8974fe..c46ebcd6 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, (1, 30)); } #[test] diff --git a/crates/tinysecurity-bus/src/lib.rs b/crates/tinysecurity-bus/src/lib.rs index 633dab84..107fbf83 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) = (1, 30); /// 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/names.rs b/crates/tinysecurity-bus/src/names.rs index 7380ca9d..d12e6bd7 100644 --- a/crates/tinysecurity-bus/src/names.rs +++ b/crates/tinysecurity-bus/src/names.rs @@ -39,6 +39,12 @@ pub mod methods { pub const RECORD_EXECUTION2: &str = "RecordExecution2"; /// Scoped sanitized durable rows. pub const LIST_APPROVALS2: &str = "ListApprovals2"; + /// Bounded recent decisions across active approval scopes (contract 1.30). + pub const LIST_RECENT_DECISIONS2: &str = "ListRecentDecisions2"; + /// Purge undecided rows for one session across active approval scopes (contract 1.30). + pub const PURGE_APPROVAL_SESSION2: &str = "PurgeApprovalSession2"; + /// Persist redacted metadata after an audit sink failure (contract 1.30). + pub const RECORD_AUDIT_ERROR2: &str = "RecordAuditError2"; /// Captured-scope cancellation phase. pub const CANCEL_APPROVAL2: &str = "CancelApproval2"; /// Durable exact-boundary expiry phase. @@ -239,6 +245,9 @@ pub const METHODS: &[&str] = &[ methods::CONSUME_APPROVAL2, methods::RECORD_EXECUTION2, methods::LIST_APPROVALS2, + methods::LIST_RECENT_DECISIONS2, + methods::PURGE_APPROVAL_SESSION2, + methods::RECORD_AUDIT_ERROR2, methods::CANCEL_APPROVAL2, methods::EXPIRE_APPROVAL2, methods::PREAUTHORIZE_FLOW2, diff --git a/crates/tinysecurity-bus/src/policy.rs b/crates/tinysecurity-bus/src/policy.rs index 8c3ba1cc..c958f535 100644 --- a/crates/tinysecurity-bus/src/policy.rs +++ b/crates/tinysecurity-bus/src/policy.rs @@ -50,6 +50,8 @@ pub enum Origin { /// evaluation denies it unless the registered scope holds a whole-tool grant for /// the tool, which approves it as an audited automatic approval. Unlabelled, + /// Origin classification failed or was unavailable; treated as untrusted. + Unknown, } /// Access ceiling verified by the host. #[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/tinysecurity-bus/tests/scoped_wire.rs b/crates/tinysecurity-bus/tests/scoped_wire.rs index a861de47..25ebc13d 100644 --- a/crates/tinysecurity-bus/tests/scoped_wire.rs +++ b/crates/tinysecurity-bus/tests/scoped_wire.rs @@ -80,6 +80,9 @@ fn new_members_are_advertised_and_named_by_contract_constants() { names::methods::CONSUME_BROWSER_DIAL, names::methods::VALIDATE_BROWSER_DIAL, names::methods::REGISTER_SECRET_SCOPE2, + names::methods::LIST_RECENT_DECISIONS2, + names::methods::PURGE_APPROVAL_SESSION2, + names::methods::RECORD_AUDIT_ERROR2, ] { assert!(names::METHODS.contains(&name), "{name}"); } @@ -88,9 +91,11 @@ 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))); + assert_eq!(CONTRACT_VERSION, (1, 30)); + // A host built against 1.30 accepts this module and newer minors, not a 1.29 module. + assert!(is_compatible((1, 30))); + assert!(is_compatible((1, 31))); + assert!(!is_compatible((1, 29))); assert!(!is_compatible((1, 28))); assert!(!is_compatible((1, 26))); assert!(!is_compatible((1, 25))); diff --git a/crates/tinysecurity-bus/tests/wire.rs b/crates/tinysecurity-bus/tests/wire.rs index ac0a2e2f..5bce67cf 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, 29)); + assert_eq!(CONTRACT_VERSION, (1, 30)); for (mode, wire) in [ (RedactMode::Text, "text"), (RedactMode::StructuredArguments, "structured_arguments"), diff --git a/crates/tinysecurity-crypto/src/native_handles_tests.rs b/crates/tinysecurity-crypto/src/native_handles_tests.rs index 0e63a501..40bafb1d 100644 --- a/crates/tinysecurity-crypto/src/native_handles_tests.rs +++ b/crates/tinysecurity-crypto/src/native_handles_tests.rs @@ -543,6 +543,8 @@ pub(super) fn human_receipt(binding: &Binding) -> Result { host_instance: "4".repeat(32), nonce: "5".repeat(32), tool_name: "native_secret_grant".into(), + session_id: None, + audit_error: None, external_effect: false, args_redacted: binding.safe_arguments(), summary: "native key authority".into(), diff --git a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs index 6dc4c13d..d0e9a2d5 100644 --- a/crates/tinysecurity-module/examples/approval_native_contract/callback.rs +++ b/crates/tinysecurity-module/examples/approval_native_contract/callback.rs @@ -8,8 +8,9 @@ use std::sync::{ use tinybus::{BusName, CallContext, Connection, Interface, InterfaceName, MemberName}; use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalChoice2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, - ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalRow2, DecideRequest2, SubjectScope, - VerifyHumanRequest2, callback_names2, names, + ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalPurgeSessionRequest2, + ApprovalPurgeSessionResponse2, ApprovalRow2, DecideRequest2, SubjectScope, VerifyHumanRequest2, + callback_names2, names, }; #[derive(Clone)] pub(crate) struct Callback { @@ -27,14 +28,18 @@ impl Interface for Callback { self.interface.clone() } fn members(&self) -> Vec { - let names = if self.interface.as_str() == callback_names2::APPROVAL_STORE { - [callback_names2::LOAD, callback_names2::CAS] + let names: &[&str] = if self.interface.as_str() == callback_names2::APPROVAL_STORE { + &[ + callback_names2::LOAD, + callback_names2::CAS, + callback_names2::PURGE_SESSION, + ] } else { - [callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] + &[callback_names2::PROMPT, callback_names2::VERIFY_HUMAN] }; names - .into_iter() - .filter_map(|name| name.parse().ok()) + .iter() + .filter_map(|name| (*name).parse().ok()) .collect() } @@ -77,6 +82,16 @@ impl Interface for Callback { ) .map_err(Into::into) } + callback_names2::PURGE_SESSION => { + let (request,): (ApprovalPurgeSessionRequest2,) = serde_json::from_value(args)?; + self.scope(&request.scope)?; + serde_json::to_value(ApprovalPurgeSessionResponse2 { + scope: request.scope, + session_id: request.session_id, + removed: 0, + }) + .map_err(Into::into) + } callback_names2::VERIFY_HUMAN => { let (request,): (VerifyHumanRequest2,) = serde_json::from_value(args)?; self.scope(&request.row.binding.scope)?; diff --git a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs index 70280077..62ab67c9 100644 --- a/crates/tinysecurity-module/examples/approval_scope_native_contract.rs +++ b/crates/tinysecurity-module/examples/approval_scope_native_contract.rs @@ -17,7 +17,7 @@ use tinysecurity_bus::{ ApprovalScopeOutcome2, ApprovalScopeReceipt2, AuditConfig2, AuditCorrelation2, AuditEventReceipt2, AuditSinkRequest2, AuditSinkResponse2, CallerContext, Check, DecideRequest2, EvaluateApprovalRequest2, EvaluateApprovalResponse2, EvaluateRequest, - HumanDecisionReceipt2, ModuleConfig, RegisterApprovalScopeRequest2, + HumanDecisionReceipt2, ModuleConfig, Origin, RegisterApprovalScopeRequest2, RetireApprovalScopeRequest2, SubjectScope, ToolCall, Verdict, VerifyHumanRequest2, names, }; @@ -41,7 +41,7 @@ fn registration(grants: ApprovalGrants2) -> RegisterApprovalScopeRequest2 { policy_ref: "tenant-policy".into(), generation: 1, ceiling: AccessTier::Full, - require_approval: true, + require_approval: false, auto_approve_all: false, }, grants, @@ -64,6 +64,7 @@ fn begin(instance: u64, sequence: u64, tool: &str) -> EvaluateApprovalRequest2 { agent: "tenant-agent".into(), call_id: format!("execution-{instance}"), tier: AccessTier::Full, + origin: Origin::Saas, ..Default::default() }, call: ToolCall { diff --git a/crates/tinysecurity-module/src/adapter.rs b/crates/tinysecurity-module/src/adapter.rs index 45baf06c..b989130e 100644 --- a/crates/tinysecurity-module/src/adapter.rs +++ b/crates/tinysecurity-module/src/adapter.rs @@ -1047,6 +1047,39 @@ impl PolicyService { .await .map_err(approval_failure) } + async fn list_recent_decisions2( + &self, + request: tinysecurity_bus::RecentApprovalDecisionsRequest2, + ) -> BusResult> { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .list_recent_decisions(request.limit) + .await + .map_err(approval_failure) + } + async fn purge_approval_session2( + &self, + request: tinysecurity_bus::PurgeApprovalSessionRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .purge_session(&request.session_id) + .await + .map_err(approval_failure) + } + async fn record_audit_error2( + &self, + request: tinysecurity_bus::RecordAuditErrorRequest2, + ) -> BusResult { + self.approvals + .as_ref() + .ok_or_else(|| tinybus::Error::failed("approval unavailable"))? + .record_audit_error(request) + .await + .map_err(approval_failure) + } async fn cancel_approval2( &self, request: tinysecurity_bus::ApprovalRecordRequest2, @@ -2172,7 +2205,7 @@ tinybus_module::module_export! { config = ModuleConfig, worker_threads = 1, provides = ["ai.tinyhumans.tinysecurity.Policy"], - methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], + methods = ["EvaluateSecretCipher2", "ExecuteSecretCipher2", "EvaluateSecret2", "ExecuteSecret2", "PasswordCrypt2", "KeyringAccess2", "PairingControl2", "EvaluateDevice2", "ExecuteDevice2", "DeviceFrame2", "DevicePairing2", "Plan", "Plan2", "EvaluateSandboxApproval2", "ConsumeSandboxPlan2", "ImportApprovalHistory2", "ParseReply", "EvaluateApproval2", "Decide2", "ConsumeApproval2", "RecordExecution2", "ListApprovals2", "ListRecentDecisions2", "PurgeApprovalSession2", "RecordAuditError2", "CancelApproval2", "ExpireApproval2", "PreauthorizeFlow2", "GrantFlowTool2", "RevokeFlowTool2", "ListFlowTrust2", "GrantTool2", "RevokeTool2", "RevokeToolById2", "ListToolTrust2", "ImportTrustSource2", "RegisterApprovalScope2", "ReplaceApprovalScope2", "RetireApprovalScope2", "RegisterSecretScope2", "RegisterSandboxScope2", "RetireSandboxScope2", "Audit2", "RecordAudit2", "Evaluate", "Check", "EvaluateAudited2", "CheckAudited2", "PolicyInfo", "RegisterPolicy", "SupersedePolicy", "EvaluateScoped", "CheckScoped", "ScopedPolicyInfo", "EvaluateScopedAudited", "CheckScopedAudited", "RegisterPathPolicy", "ValidatePath", "ValidateParent", "CheckPath", "CheckPaths2", "ProtectedPath2", "ProtectedFloor2", "CredentialRoots2", "SpendAction2", "PeekBudget2", "ClassifyHostAction2", "PrivacyCheck2", "RegisterCommandPolicy", "ClassifyCommand", "CheckCommand", "RegisterEgressPolicy", "ScopedUrlGuard", "ScopedEgressCheck", "ConsumeEgressAuthorization", "AuthorizeBrowserDial", "ConsumeBrowserDial", "ValidateBrowserDial", "UrlGuard", "EgressCheck", "Redact", "RedactWith", "ScanPrompt", "ScanPrompt2", "ScanToolDefinition"], signals = ["PolicyChanged"], requires = [], optional = ["ai.tinyhumans.tinysecurity.KeyringConsent2", "ai.tinyhumans.tinysecurity.SecretCipherConsumer2", "ai.tinyhumans.tinysecurity.SecretBackend2", "ai.tinyhumans.tinysecurity.DevicePairingHost2", "ai.tinyhumans.tinysecurity.AuditSink2", "ai.tinyhumans.tinysecurity.ApprovalStore2", "ai.tinyhumans.tinysecurity.ApprovalPrompt2"], diff --git a/crates/tinysecurity-module/src/adapter_approval_tests.rs b/crates/tinysecurity-module/src/adapter_approval_tests.rs index 99ea22fa..f31bb87c 100644 --- a/crates/tinysecurity-module/src/adapter_approval_tests.rs +++ b/crates/tinysecurity-module/src/adapter_approval_tests.rs @@ -116,6 +116,79 @@ async fn authenticated_module_routes_persistent_decisions_reservation_history_an broker.abort(); Ok(()) } + +#[tokio::test] +async fn recent_decisions_session_purge_and_audit_error_use_the_approval_engine() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = Connection::connect(bus.connect().await?).await?; + let module = Connection::connect(bus.connect().await?).await?; + let dir = tempfile::tempdir()?; + let path = dir.path().join("ledger"); + std::fs::write(&path, [])?; + let store = Arc::new(Mutex::new(store::DiskStore::open(&path)?)); + let clock = Arc::new(TestClock(AtomicU64::new(1000))); + let failure = Arc::new(AtomicBool::new(false)); + serve_approvals(&host, &module, &clock, &store, &failure, None).await?; + let module_peer = module.unique_name().ok_or("module peer")?; + let proxy = host.proxy(module_peer.as_str(), names::OBJECT_PATH, names::INTERFACE)?; + + let approved: EvaluateApprovalResponse2 = proxy + .call(names::methods::EVALUATE_APPROVAL2, (begin(1, 1),)) + .await?; + let reserved: ApprovalRow2 = proxy + .call( + names::methods::CONSUME_APPROVAL2, + (action(&approved.record, 1, 3),), + ) + .await?; + let terminal: ApprovalRow2 = proxy + .call( + names::methods::RECORD_EXECUTION2, + (RecordExecutionRequest2 { + action: action(&reserved, 1, 4), + outcome: ExecutionOutcome::Succeeded, + },), + ) + .await?; + let saved_error: ApprovalRow2 = proxy + .call( + names::methods::RECORD_AUDIT_ERROR2, + (RecordAuditErrorRequest2 { + scope_id: "a".repeat(32), + request_id: terminal.request_id.clone(), + audit_error: "sink unavailable".into(), + audit: audit(5), + },), + ) + .await?; + assert_eq!(saved_error.audit_error.as_deref(), Some("sink unavailable")); + + let recent: Vec = proxy + .call( + names::methods::LIST_RECENT_DECISIONS2, + (RecentApprovalDecisionsRequest2 { limit: 10 },), + ) + .await?; + assert!( + recent + .iter() + .any(|row| row.request_id == approved.record.request_id) + ); + + let removed: usize = proxy + .call( + names::methods::PURGE_APPROVAL_SESSION2, + (PurgeApprovalSessionRequest2 { + session_id: "thread".into(), + },), + ) + .await?; + assert_eq!(removed, 0); + broker.abort(); + Ok(()) +} #[tokio::test] async fn admission_questions_are_not_downgraded_by_configured_approvals() -> std::result::Result<(), Box> { diff --git a/crates/tinysecurity-module/src/adapter_scope_tests.rs b/crates/tinysecurity-module/src/adapter_scope_tests.rs index c02ef936..d3480535 100644 --- a/crates/tinysecurity-module/src/adapter_scope_tests.rs +++ b/crates/tinysecurity-module/src/adapter_scope_tests.rs @@ -170,19 +170,34 @@ async fn runtime_scope_registers_grants_replaces_and_retires_through_the_authent let handle = tinysecurity::approval::scope_handle(&tenant_subject())?; assert_eq!(created.scope_handle, Some(handle)); - // A granted tool is approved without a prompt, persisted and audited as the grant. + // Required approval takes precedence over the registered tool allowlist. let granted: EvaluateApprovalResponse2 = proxy .call( names::methods::EVALUATE_APPROVAL2, (tenant_begin(2, 3, "shell"),), ) .await?; - assert_eq!(granted.decision.verdict, Verdict::Allow); - assert_eq!( - granted.record.attribution.as_deref(), - Some("auto:tool-grant") - ); - // Any other tool still needs the tenant's own human. + assert!(matches!( + granted.decision.verdict, + Verdict::RequireApproval { .. } + )); + let ticket = store + .lock() + .map_err(|_| "fixture lock")? + .human_action(granted.record.clone(), ApprovalChoice2::Once)?; + let granted = proxy + .call::( + names::methods::DECIDE2, + (DecideRequest2 { + scope_id: TENANT.into(), + request_id: granted.record.request_id.clone(), + decision_ticket: ticket, + audit: audit(4), + },), + ) + .await?; + assert_eq!(granted.state, ApprovalLifecycle2::Approved); + // Other tools also require the tenant's own human. let pending: EvaluateApprovalResponse2 = proxy .call( names::methods::EVALUATE_APPROVAL2, diff --git a/crates/tinysecurity-module/src/approval_callbacks.rs b/crates/tinysecurity-module/src/approval_callbacks.rs index 83894666..5544985a 100644 --- a/crates/tinysecurity-module/src/approval_callbacks.rs +++ b/crates/tinysecurity-module/src/approval_callbacks.rs @@ -6,8 +6,9 @@ use tinysecurity::approval::{Error, NativeAuditAcknowledgement, Ports, Result}; use tinysecurity_bus::{ ApprovalCasRequest2, ApprovalCasResponse2, ApprovalConfig2, ApprovalDeliveryRequest2, ApprovalDeliveryResponse2, ApprovalLifecycle2, ApprovalLoadRequest2, ApprovalLoadResponse2, - ApprovalRow2, ApprovalScope2, AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, - AuditSinkResponse2, HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, + ApprovalPurgeSessionRequest2, ApprovalPurgeSessionResponse2, ApprovalRow2, ApprovalScope2, + AuditConfig2, AuditEvent2, AuditKind2, AuditSinkRequest2, AuditSinkResponse2, + HumanDecisionReceipt2, SubjectScope, VerifyHumanRequest2, callback_names2, }; /// Captured registered callback identity; no model-provided destination or scope. pub(crate) struct ApprovalStoreClient { @@ -112,6 +113,21 @@ impl ApprovalStoreClient { .map_err(|_| Error::Invalid)?; Ok(response) } + pub(crate) async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> Result { + self.scope(&request.scope)?; + let response = self + .proxy(callback_names2::APPROVAL_STORE)? + .call(callback_names2::PURGE_SESSION, (request.clone(),)) + .await + .map_err(|_| Error::Unavailable)?; + request + .verify_response(&response) + .map_err(|_| Error::Invalid)?; + Ok(response) + } } /// Real authenticated store, delivery, human-ticket verification and audit ports. pub(crate) struct ApprovalClients { @@ -196,6 +212,12 @@ impl Ports for ApprovalClients { async fn cas(&self, request: ApprovalCasRequest2) -> Result { self.store.cas(request).await } + async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> Result { + self.store.purge_session(request).await + } async fn prompt(&self, request: ApprovalDeliveryRequest2) -> Result { self.store.scope(&request.row.binding.scope)?; let proxy = self.store.proxy(callback_names2::APPROVAL_PROMPT)?; diff --git a/crates/tinysecurity-module/src/approval_callbacks_tests.rs b/crates/tinysecurity-module/src/approval_callbacks_tests.rs index 6c866fea..d830197f 100644 --- a/crates/tinysecurity-module/src/approval_callbacks_tests.rs +++ b/crates/tinysecurity-module/src/approval_callbacks_tests.rs @@ -32,6 +32,26 @@ impl Store { })) .await } + + async fn purge_session( + &self, + request: ApprovalPurgeSessionRequest2, + ) -> tinybus::Result { + std::future::ready(Ok(ApprovalPurgeSessionResponse2 { + scope: if self.wrong { + self.scope.clone() + } else { + request.scope + }, + session_id: if self.wrong { + "wrong-session".into() + } else { + request.session_id + }, + removed: 1, + })) + .await + } } fn config() -> ApprovalConfig2 { ApprovalConfig2 { @@ -153,6 +173,62 @@ async fn real_store_clients_validate_exact_scope_receipt_and_fixed_unique_peer() broker.abort(); Ok(()) } + +#[tokio::test] +async fn store_client_rejects_empty_init_scope_list() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let mut config = config(); + config.scopes.clear(); + + assert!( + ApprovalStoreClient::new( + module, + host.unique_name().as_ref().map(tinybus::BusName::as_str), + &config, + ) + .is_err() + ); + broker.abort(); + Ok(()) +} + +#[tokio::test] +async fn store_client_purges_session_only_through_registered_scope() +-> std::result::Result<(), Box> { + let bus = MemoryBus::new(); + let broker = Broker::new().spawn(bus.clone()); + let host = tinybus::Connection::connect(bus.connect().await?).await?; + let module = tinybus::Connection::connect(bus.connect().await?).await?; + let config = config(); + let scope = tinysecurity::approval::scope_handle(&config.scopes[0].subject)?; + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + Store { + scope: scope.clone(), + wrong: false, + }, + ) + .await?; + let client = ApprovalStoreClient::new( + module, + host.unique_name().as_ref().map(tinybus::BusName::as_str), + &config, + )?; + let request = ApprovalPurgeSessionRequest2 { + scope: scope.clone(), + session_id: "session-private".into(), + }; + assert_eq!(client.purge_session(request.clone()).await?.removed, 1); + let mut outside_scope = request; + outside_scope.scope.user = "other".into(); + assert!(client.purge_session(outside_scope).await.is_err()); + broker.abort(); + Ok(()) +} #[tokio::test] async fn unrelated_receipts_do_not_confirm_store_commits() -> std::result::Result<(), Box> { @@ -189,7 +265,7 @@ async fn unrelated_receipts_do_not_confirm_store_commits() assert!( client .cas(ApprovalCasRequest2 { - scope, + scope: scope.clone(), record_id: "b".repeat(32), expected_version: None, next_record: ApprovalRecord2 { @@ -205,6 +281,15 @@ async fn unrelated_receipts_do_not_confirm_store_commits() .await .is_err() ); + assert!( + client + .purge_session(ApprovalPurgeSessionRequest2 { + scope, + session_id: "session-private".into(), + }) + .await + .is_err() + ); broker.abort(); Ok(()) } @@ -486,6 +571,8 @@ fn audit_row(scope: SubjectScope) -> ApprovalRow2 { host_instance: "f".repeat(32), nonce: "1".repeat(32), tool_name: "native_secret_grant".into(), + session_id: None, + audit_error: None, external_effect: true, args_redacted: "{}".into(), summary: "native grant".into(), @@ -862,6 +949,14 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() let base = config(); let redactor = tinysecurity::Redactor::new(&tinysecurity_bus::RedactorConfig { secrets: vec![] })?; + host.serve_at( + callback_names2::OBJECT_PATH.parse()?, + Store { + scope: base.scopes[0].subject.clone(), + wrong: false, + }, + ) + .await?; let clients = ApprovalClients::new( &module, Some(peer.as_str()), @@ -869,6 +964,19 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() &native_audit_config(base.scopes[0].subject.clone(), false), &redactor, )?; + assert_eq!(format!("{clients:?}"), "ApprovalClients([REDACTED])"); + let mut invalid_audit = native_audit_config(base.scopes[0].subject.clone(), false); + invalid_audit.stream_id = "short".into(); + assert!( + ApprovalClients::new( + &module, + Some(peer.as_str()), + &base, + &invalid_audit, + &redactor, + ) + .is_err() + ); let scope = runtime_scope(2); let handle = tinysecurity::approval::scope_handle(&scope.subject)?; // A malformed stream never admits the subject. @@ -880,6 +988,18 @@ async fn clients_admit_a_runtime_subject_with_its_own_stream_and_release_it() assert!(clients.admit_scope(&scope, &"e".repeat(32)).await.is_err()); clients.admit_scope(&scope, &"d".repeat(32)).await?; assert!(clients.store.scope(&handle).is_ok()); + let purge = ApprovalPurgeSessionRequest2 { + scope: handle.clone(), + session_id: "session-private".into(), + }; + assert_eq!( + clients.purge_session(purge).await?, + ApprovalPurgeSessionResponse2 { + scope: handle.clone(), + session_id: "session-private".into(), + removed: 1, + } + ); { let held = clients.audit.read().map_err(|_| "lock")?; assert_eq!(held.iter().filter(|(s, _)| s == &handle).count(), 1);