diff --git a/Cargo.lock b/Cargo.lock index 74a2802..27ced29 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3104,7 +3104,7 @@ dependencies = [ [[package]] name = "graphql-orm-ai" -version = "0.93.1" +version = "0.93.2" dependencies = [ "agql-auth", "async-graphql", diff --git a/crates/graphql-orm-ai/CHANGELOG.md b/crates/graphql-orm-ai/CHANGELOG.md index a16b984..8b4d580 100644 --- a/crates/graphql-orm-ai/CHANGELOG.md +++ b/crates/graphql-orm-ai/CHANGELOG.md @@ -18,6 +18,30 @@ checkpoint facts. For the current workspace baseline and active gates, use the [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## [0.93.2] - 2026-08-23 + +Persistent schema module: **0.63.0** (unchanged from 0.93.1). + +### Fixed + +- Failed application-tool previews now require the descriptor's explicit + browser-preview opt-in and the same fresh current host tool-policy + preauthorization as successful previews. +- Egress-denied rows are never browser-previewable, even if their persisted + authorization code resembles a public failure envelope. +- The safe failure-code browser mapping now has an exhaustive enum guard, so + adding a failure variant requires an explicit visibility decision. + +### Security + +- Failure previews remain current-owner, current-session/scope-authority, + exact-descriptor, public-classification, exact-envelope, and host-projection + gated. Revoked tool policy or missing browser opt-in now fails closed before + protected arguments or results can cross the browser boundary. + +There is no database, data, table, column, index, constraint, backfill, +protected-payload, GraphQL SDL, backup, or restore migration. + ## [0.93.1] - 2026-08-23 Persistent schema module: **0.63.0** (unchanged from 0.93.0). diff --git a/crates/graphql-orm-ai/Cargo.toml b/crates/graphql-orm-ai/Cargo.toml index 776a1aa..31904bc 100644 --- a/crates/graphql-orm-ai/Cargo.toml +++ b/crates/graphql-orm-ai/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "graphql-orm-ai" -version = "0.93.1" +version = "0.93.2" edition = "2024" authors = ["Toby Martin "] description = "Project-agnostic AI agent runtime for graphql-orm applications" diff --git a/crates/graphql-orm-ai/MIGRATION.md b/crates/graphql-orm-ai/MIGRATION.md index 8297bd0..5864f3f 100644 --- a/crates/graphql-orm-ai/MIGRATION.md +++ b/crates/graphql-orm-ai/MIGRATION.md @@ -19,6 +19,20 @@ they describe. For the current workspace baseline and active delivery gates, use [implementation status](docs/implementation-status.md) and the central [AI production-readiness plan](../../docs/plans/active/ai-production-readiness/README.md). +## 0.93.1 to 0.93.2: consistent current-policy browser preview gates + +Adopt `graphql-orm-ai` 0.93.2 from one reviewed full monorepo revision. The AI +schema module remains **0.63.0**. There is no database, data, table, column, +index, constraint, backfill, protected-payload, GraphQL SDL, backup, or restore +migration. + +Hosts do not need to change an API implementation. Failed tool-call previews +now require an exact current descriptor with `browser_result_preview` enabled +and a fresh successful host tool-policy preauthorization, matching successful +preview behavior. Egress-denied rows no longer produce a browser preview. +Existing `AiToolResultPreviewAuthorizer` implementations that do not override +`authorize_and_project_arguments` continue returning `None` for arguments. + ## 0.93.0 to 0.93.1: public web-search configuration export Adopt `graphql-orm-ai` 0.93.1 from one reviewed full monorepo revision. The AI diff --git a/crates/graphql-orm-ai/README.md b/crates/graphql-orm-ai/README.md index a930396..774d8d4 100644 --- a/crates/graphql-orm-ai/README.md +++ b/crates/graphql-orm-ai/README.md @@ -3,7 +3,7 @@ title: "graphql-orm-ai" kind: reference status: active owner: graphql-orm-ai-maintainers -last_reviewed: 2026-08-16 +last_reviewed: 2026-08-23 review_by: 2027-02-01 supersedes: [] --- @@ -28,7 +28,7 @@ for AI, ORM, storage, backup, and tool-profile packages: ```toml [dependencies] -graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.93.1", default-features = false, features = ["sqlite"] } +graphql-orm-ai = { git = "https://github.com/Dastari/graphql-orm.git", rev = "", version = "0.93.2", default-features = false, features = ["sqlite"] } ``` Exactly one persistence backend is required: `sqlite` (default), `postgres`, @@ -80,9 +80,11 @@ the compiled test-backed recipe and the missing reusable bootstrap API. messages, durable watermark, active/recent runs, tool calls, provider activity and retention reset state for race-free replay/live handoff. - An owner-authorized tool-call preview rehydrates current authority before - returning host-projected arguments and either a disclosure-validated result - or the exact content-free safe failure envelope. Secret results never enter - this browser contract. + requiring the exact descriptor's browser-preview opt-in and freshly + reauthorizing current host tool policy. It then returns host-projected + arguments and either a disclosure-validated result or the exact + content-free safe failure envelope. Egress-denied and secret results never + enter this browser contract. - Provider-neutral adapters plus deterministic network-free mocks. - Optional provider profiles, attachments, skills, UI intents, rules, and usage/pricing controls, each behind independent proof and policy boundaries. diff --git a/crates/graphql-orm-ai/src/orm_tool_result_preview.rs b/crates/graphql-orm-ai/src/orm_tool_result_preview.rs index 2fa30d4..bf192ef 100644 --- a/crates/graphql-orm-ai/src/orm_tool_result_preview.rs +++ b/crates/graphql-orm-ai/src/orm_tool_result_preview.rs @@ -149,7 +149,7 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { .authorization_code .as_deref() .and_then(application_tool_failure_code) - .filter(|_| matches!(call.state.as_str(), "execution_failed" | "egress_denied")); + .filter(|_| call.state == "execution_failed"); if call.run_id != run.id || (!successful && safe_failure.is_none()) || call.completed_at.is_none() @@ -164,7 +164,12 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { .runtime .tool_catalog() .descriptor(&tool_id) - .filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint); + .filter(|descriptor| descriptor.fingerprint == call.tool_fingerprint) + .ok_or(AiError::Forbidden)?; + let preview_policy = match descriptor.browser_result_preview { + Some(policy) => policy, + None => return Ok(None), + }; let policy = self .runtime .content_protection_policy_resolver() @@ -173,73 +178,6 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { if !policy.ready || policy.scope != scope { return Err(AiError::RuntimeNotReady); } - if let Some(code) = safe_failure { - let classification = parse_classification( - call.result_classification - .as_deref() - .ok_or(AiError::PersistenceFailed)?, - )?; - if classification != DataClassification::Public { - return Err(AiError::PersistenceFailed); - } - let stored = self - .open( - &policy, - protection_context(call.id, "protected_result", &scope), - call.protected_result - .as_ref() - .ok_or(AiError::PersistenceFailed)?, - ) - .await?; - let preview = extract_safe_failure(&stored, code)?; - let arguments = if let Some(descriptor) = descriptor - && descriptor.browser_result_preview.is_some() - { - let stored_arguments = self - .open( - &policy, - protection_context(call.id, "protected_arguments", &scope), - call.protected_arguments - .as_ref() - .ok_or(AiError::PersistenceFailed)?, - ) - .await?; - self.project_arguments(¤t, &scope, descriptor, &stored_arguments) - .await? - } else { - None - }; - return Ok(Some(AiToolCallResultPreviewView { - session_id: session.id, - run_id: run.id, - tool_call_id: call.id, - tool_id: call.tool_id, - classification: classification_name(classification).to_owned(), - arguments: arguments.map(async_graphql::Json), - preview: async_graphql::Json(preview), - })); - } - let descriptor = descriptor.ok_or(AiError::Forbidden)?; - let preview_policy = match descriptor.browser_result_preview { - Some(policy) => policy, - None => return Ok(None), - }; - let disclosure = self - .runtime - .tool_catalog() - .disclosure_schema(&tool_id) - .filter(|schema| { - call.disclosure_schema_fingerprint.as_deref() == Some(schema.fingerprint.as_str()) - }) - .ok_or(AiError::Forbidden)?; - let classification = parse_classification( - call.result_classification - .as_deref() - .ok_or(AiError::PersistenceFailed)?, - )?; - if classification > preview_policy.maximum_classification { - return Ok(None); - } let arguments = self .open( &policy, @@ -289,6 +227,51 @@ impl AiToolCallResultPreviewService for OrmAiToolCallResultPreviewService { &request.variables, ) .await?; + if let Some(code) = safe_failure { + let classification = parse_classification( + call.result_classification + .as_deref() + .ok_or(AiError::PersistenceFailed)?, + )?; + if classification != DataClassification::Public { + return Err(AiError::PersistenceFailed); + } + let stored = self + .open( + &policy, + protection_context(call.id, "protected_result", &scope), + call.protected_result + .as_ref() + .ok_or(AiError::PersistenceFailed)?, + ) + .await?; + let preview = extract_safe_failure(&stored, code)?; + return Ok(Some(AiToolCallResultPreviewView { + session_id: session.id, + run_id: run.id, + tool_call_id: call.id, + tool_id: call.tool_id, + classification: classification_name(classification).to_owned(), + arguments: arguments.map(async_graphql::Json), + preview: async_graphql::Json(preview), + })); + } + let disclosure = self + .runtime + .tool_catalog() + .disclosure_schema(&tool_id) + .filter(|schema| { + call.disclosure_schema_fingerprint.as_deref() == Some(schema.fingerprint.as_str()) + }) + .ok_or(AiError::Forbidden)?; + let classification = parse_classification( + call.result_classification + .as_deref() + .ok_or(AiError::PersistenceFailed)?, + )?; + if classification > preview_policy.maximum_classification { + return Ok(None); + } let stored = self .open( &policy, @@ -386,18 +369,38 @@ fn extract_exact_result(value: &serde_json::Value) -> Result<&serde_json::Value, fn application_tool_failure_code(value: &str) -> Option { use crate::AiApplicationToolFailureCode as Code; - match value { - "invalid_arguments" => Some(Code::InvalidArguments), - "selection_too_large" => Some(Code::SelectionTooLarge), - "relationship_depth_exceeded" => Some(Code::RelationshipDepthExceeded), - "result_budget_exceeded" => Some(Code::ResultBudgetExceeded), - "capability_stale" => Some(Code::CapabilityStale), - "authorization_denied" => Some(Code::AuthorizationDenied), - "temporarily_unavailable" => Some(Code::TemporarilyUnavailable), - "tool_unavailable" => Some(Code::ToolUnavailable), - "resolver_validation_failed" => Some(Code::ResolverValidationFailed), - "not_found" => Some(Code::NotFound), - _ => None, + let code = match value { + "invalid_arguments" => Code::InvalidArguments, + "selection_too_large" => Code::SelectionTooLarge, + "relationship_depth_exceeded" => Code::RelationshipDepthExceeded, + "result_budget_exceeded" => Code::ResultBudgetExceeded, + "capability_stale" => Code::CapabilityStale, + "authorization_denied" => Code::AuthorizationDenied, + "temporarily_unavailable" => Code::TemporarilyUnavailable, + "tool_unavailable" => Code::ToolUnavailable, + "resolver_validation_failed" => Code::ResolverValidationFailed, + "not_found" => Code::NotFound, + _ => return None, + }; + Some(exhaustive_browser_failure_code(code)) +} + +const fn exhaustive_browser_failure_code( + code: crate::AiApplicationToolFailureCode, +) -> crate::AiApplicationToolFailureCode { + use crate::AiApplicationToolFailureCode as Code; + + match code { + Code::InvalidArguments + | Code::SelectionTooLarge + | Code::RelationshipDepthExceeded + | Code::ResultBudgetExceeded + | Code::CapabilityStale + | Code::AuthorizationDenied + | Code::TemporarilyUnavailable + | Code::ToolUnavailable + | Code::ResolverValidationFailed + | Code::NotFound => code, } } diff --git a/crates/graphql-orm-ai/src/provider_calls.rs b/crates/graphql-orm-ai/src/provider_calls.rs index d82d93b..6558f01 100644 --- a/crates/graphql-orm-ai/src/provider_calls.rs +++ b/crates/graphql-orm-ai/src/provider_calls.rs @@ -4298,6 +4298,22 @@ mod tests { struct RecordIdPreviewAuthorizer(Arc); + struct ResultOnlyPreviewAuthorizer; + + fn project_record_id( + request: &ToolGraphqlRequest, + result: &serde_json::Value, + ) -> Option { + let result_id = result + .get("record") + .and_then(|record| record.get("recordId")); + (result_id == request.variables.get("recordId")).then(|| { + json!({"record": { + "recordId": result_id.cloned().unwrap_or_default(), + }}) + }) + } + #[async_trait] impl AiToolResultPreviewAuthorizer for RecordIdPreviewAuthorizer { async fn authorize_and_project_arguments( @@ -4321,15 +4337,21 @@ mod tests { if !self.0.load(Ordering::SeqCst) { return Ok(None); } - let result_id = result - .get("record") - .and_then(|record| record.get("recordId")); - if result_id != request.variables.get("recordId") { - return Ok(None); - } - Ok(Some(json!({"record": { - "recordId": result_id.cloned().unwrap_or_default(), - }}))) + Ok(project_record_id(request, result)) + } + } + + #[async_trait] + impl AiToolResultPreviewAuthorizer for ResultOnlyPreviewAuthorizer { + async fn authorize_and_project( + &self, + _principal: &ResolvedPrincipal, + _scope: &AiScope, + _descriptor: &AiToolDescriptor, + request: &ToolGraphqlRequest, + result: &serde_json::Value, + ) -> Result, AiError> { + Ok(project_record_id(request, result)) } } @@ -10800,6 +10822,27 @@ mod tests { Some(json!({"recordId": "54"})) ); assert_eq!(preview.preview.0, json!({"record": {"recordId": "54"}})); + let result_only_preview_service = OrmAiToolCallResultPreviewService::new( + fixture.database.clone(), + fixture.runtime.clone(), + Arc::new(ResultOnlyPreviewAuthorizer), + ); + let result_only_preview = result_only_preview_service + .result_preview( + &fixture.principal, + AiToolCallResultPreviewInput { + session_id: fixture.lease.session_id().0, + tool_call_id: persisted.id().0, + }, + ) + .await + .expect("result-only host preview should resolve") + .expect("reviewed result preview should remain present"); + assert_eq!(result_only_preview.arguments, None); + assert_eq!( + result_only_preview.preview.0, + json!({"record": {"recordId": "54"}}) + ); let successful_protected_result = record .protected_result .clone() @@ -10868,6 +10911,20 @@ mod tests { }) .await .expect("test call should become one safe failure"); + fixture.tool_policy_version.store(0, Ordering::SeqCst); + assert!(matches!( + preview_service + .result_preview( + &fixture.principal, + AiToolCallResultPreviewInput { + session_id: fixture.lease.session_id().0, + tool_call_id: persisted.id().0, + }, + ) + .await, + Err(AiError::Forbidden) + )); + fixture.tool_policy_version.store(1, Ordering::SeqCst); let failed_preview = preview_service .result_preview( &fixture.principal, @@ -10885,6 +10942,139 @@ mod tests { Some(json!({"recordId": "54"})) ); assert_eq!(failed_preview.preview.0, safe_failure); + let no_preview_descriptor = fixture + .runtime + .tool_catalog() + .descriptor(&AiToolId::parse("records.update").expect("tool ID should parse")) + .expect("fixture should register a tool without browser preview"); + assert!(no_preview_descriptor.browser_result_preview.is_none()); + let failed_tool_id = record.tool_id.clone(); + let failed_tool_fingerprint = record.tool_fingerprint.clone(); + let no_preview_fingerprint = no_preview_descriptor.fingerprint.clone(); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + tool_id: Some("records.update".to_owned()), + tool_fingerprint: Some(no_preview_fingerprint), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test failure should bind a descriptor without browser preview"); + assert!( + preview_service + .result_preview( + &fixture.principal, + AiToolCallResultPreviewInput { + session_id: fixture.lease.session_id().0, + tool_call_id: persisted.id().0, + }, + ) + .await + .expect("missing browser opt-in should be non-disclosing") + .is_none() + ); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + tool_id: Some(failed_tool_id), + tool_fingerprint: Some(failed_tool_fingerprint), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test failure should restore its preview descriptor"); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + state: Some("egress_denied".to_owned()), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test failure should become egress-denied"); + assert!( + preview_service + .result_preview( + &fixture.principal, + AiToolCallResultPreviewInput { + session_id: fixture.lease.session_id().0, + tool_call_id: persisted.id().0, + }, + ) + .await + .expect("egress denial should be non-disclosing") + .is_none() + ); + record = fixture + .database + .transaction(TransactionMode::StateMachine, move |tx| { + Box::pin(async move { + match tx + .compare_and_swap::( + &record.id, + record.row_version, + AiToolCallRecordWhereInput::default(), + UpdateAiToolCallRecordInput { + state: Some("execution_failed".to_owned()), + ..Default::default() + }, + ) + .await + .map_err(OrmPublicError::from)? + { + ConditionalUpdateOutcome::Updated(updated) => Ok(updated), + _ => Err(OrmPublicError::new(OrmErrorCode::Conflict)), + } + }) + }) + .await + .expect("test failure should restore its execution-failed state"); record = fixture .database .transaction(TransactionMode::StateMachine, move |tx| { diff --git a/crates/graphql-orm-ai/src/tool_result_preview.rs b/crates/graphql-orm-ai/src/tool_result_preview.rs index f51ef21..a3dc9a6 100644 --- a/crates/graphql-orm-ai/src/tool_result_preview.rs +++ b/crates/graphql-orm-ai/src/tool_result_preview.rs @@ -49,7 +49,7 @@ pub struct AiToolCallResultPreviewView { /// /// Implementations receive the exact registered request and an already /// protected-and-opened result after current owner, session, scope, -/// descriptor, and tool policy checks. They must apply the application's +/// descriptor opt-in, and fresh tool policy checks. They must apply the application's /// current row and field policy and return a subset suitable for this /// principal. The library validates the returned value against the /// fingerprinted descriptor policy and disclosure schema. diff --git a/docs/reference/workspace-packages.md b/docs/reference/workspace-packages.md index cb8ed71..14af0df 100644 --- a/docs/reference/workspace-packages.md +++ b/docs/reference/workspace-packages.md @@ -19,7 +19,7 @@ changes. | Package | Version | Path | Default features | Direct internal dependencies | | --- | --- | --- | --- | --- | | `graphql-orm` | `0.26.0` | `crates/graphql-orm` | `sqlite` | `graphql-orm-macros`, `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | -| `graphql-orm-ai` | `0.93.1` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | +| `graphql-orm-ai` | `0.93.2` | `crates/graphql-orm-ai` | `sqlite` | `graphql-orm`, `graphql-orm-ai-tool-profiles`, `graphql-orm-storage` | | `graphql-orm-ai-tool-profiles` | `0.9.0` | `crates/graphql-orm-ai-tool-profiles` | none | `graphql-orm-operation-catalog`, `graphql-orm-router-protocol` (dev-only) | | `graphql-orm-backup` | `0.7.1` | `crates/graphql-orm-backup` | `local` | `graphql-orm` (optional), `graphql-orm-storage` | | `graphql-orm-macros` | `0.26.0` | `crates/graphql-orm-macros` | `sqlite` | none |