From d83ab541d91f40efe12991d3b59dbd24405a82e2 Mon Sep 17 00:00:00 2001 From: github-ops Date: Wed, 24 Jun 2026 10:45:09 +0000 Subject: [PATCH] Add operation approval policy editor --- apps/admin-api/src/import_guidance.rs | 1 + apps/admin-api/src/service.rs | 5 +- apps/admin-api/src/service/demo.rs | 1 + apps/admin-api/src/service/imports.rs | 1 + .../src/service/operation_validation.rs | 90 +++++++++++++++++- apps/admin-api/tests/integration/common.rs | 1 + apps/mcp-server/tests/integration/common.rs | 1 + apps/ui/css/wizard.css | 56 ++++++++++++ apps/ui/html/wizard/step5.html | 79 ++++++++++++++++ apps/ui/js/i18n.js | 42 +++++++++ apps/ui/js/wizard-live.js | 91 ++++++++++++++++++- apps/ui/js/wizard-model.js | 8 ++ apps/ui/tests/e2e/wizard.spec.js | 35 +++++++ .../tests/unit/manifest.rs | 1 + crates/crank-core/src/lib.rs | 14 +-- crates/crank-core/src/operation.rs | 32 +++++++ crates/crank-import/src/rest/payload.rs | 1 + .../tests/integration/confirmation.rs | 1 + .../tests/integration/idempotency.rs | 1 + .../tests/integration/no_input_get.rs | 1 + 20 files changed, 451 insertions(+), 11 deletions(-) diff --git a/apps/admin-api/src/import_guidance.rs b/apps/admin-api/src/import_guidance.rs index b191455..65949bc 100644 --- a/apps/admin-api/src/import_guidance.rs +++ b/apps/admin-api/src/import_guidance.rs @@ -175,6 +175,7 @@ mod tests { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ffbe704..df47a1e 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -38,7 +38,8 @@ mod workspaces; use crate::{auth::AuthSettings, error::ApiError, storage::LocalArtifactStorage}; use operation_validation::{ - validate_idempotency_policy, validate_protocol_target, validate_response_cache_policy, + validate_approval_policy, validate_idempotency_policy, validate_protocol_target, + validate_response_cache_policy, }; #[derive(Clone)] @@ -298,6 +299,7 @@ impl AdminService { validate_protocol_target(payload.protocol, &payload.target)?; validate_response_cache_policy(&payload.target, &payload.execution_config)?; validate_idempotency_policy(&payload.target, &payload.execution_config)?; + validate_approval_policy(&payload.execution_config)?; payload.input_mapping.validate_paths()?; payload.output_mapping.validate_paths()?; Ok(()) @@ -308,6 +310,7 @@ impl AdminService { validate_protocol_target(operation.protocol, &operation.target)?; validate_response_cache_policy(&operation.target, &operation.execution_config)?; validate_idempotency_policy(&operation.target, &operation.execution_config)?; + validate_approval_policy(&operation.execution_config)?; operation.input_mapping.validate_paths()?; operation.output_mapping.validate_paths()?; Ok(()) diff --git a/apps/admin-api/src/service/demo.rs b/apps/admin-api/src/service/demo.rs index 5b7dd52..9d9e219 100644 --- a/apps/admin-api/src/service/demo.rs +++ b/apps/admin-api/src/service/demo.rs @@ -387,6 +387,7 @@ fn demo_rest_operation_payload() -> OperationPayload { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/apps/admin-api/src/service/imports.rs b/apps/admin-api/src/service/imports.rs index f98f48a..8253bf8 100644 --- a/apps/admin-api/src/service/imports.rs +++ b/apps/admin-api/src/service/imports.rs @@ -191,6 +191,7 @@ impl AdminService { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/apps/admin-api/src/service/operation_validation.rs b/apps/admin-api/src/service/operation_validation.rs index 6b55069..2bb58a3 100644 --- a/apps/admin-api/src/service/operation_validation.rs +++ b/apps/admin-api/src/service/operation_validation.rs @@ -105,16 +105,60 @@ pub(super) fn validate_idempotency_policy( Ok(()) } +pub(super) fn validate_approval_policy( + execution_config: &crank_core::ExecutionConfig, +) -> Result<(), ApiError> { + let Some(policy) = execution_config.approval_policy.as_ref() else { + return Ok(()); + }; + + if !policy.required { + return Ok(()); + } + + if policy.ttl_seconds == 0 || policy.ttl_seconds > 300 { + return Err(ApiError::validation_with_context( + "approval ttl must be between 1 and 300 seconds".to_owned(), + json!({ + "field": "execution_config.approval_policy.ttl_seconds", + }), + )); + } + + if policy.confirmation_title.trim().is_empty() { + return Err(ApiError::validation_with_context( + "approval confirmation title is required".to_owned(), + json!({ + "field": "execution_config.approval_policy.confirmation_title", + }), + )); + } + + if policy.confirmation_body_template.trim().is_empty() { + return Err(ApiError::validation_with_context( + "approval confirmation body is required".to_owned(), + json!({ + "field": "execution_config.approval_policy.confirmation_body_template", + }), + )); + } + + Ok(()) +} + #[cfg(test)] mod tests { use std::collections::BTreeMap; use crank_core::{ - ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, ResponseCachePolicy, - RestTarget, Target, + ExecutionConfig, HttpMethod, IdempotencyMode, IdempotencyPolicy, + OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel, + ResponseCachePolicy, RestTarget, Target, }; - use super::{validate_idempotency_policy, validate_response_cache_policy}; + use super::{ + validate_approval_policy, validate_idempotency_policy, validate_response_cache_policy, + }; fn cacheable_execution_config() -> ExecutionConfig { ExecutionConfig { @@ -123,6 +167,7 @@ mod tests { response_cache: Some(ResponseCachePolicy { ttl_ms: 5_000 }), idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), } @@ -140,6 +185,7 @@ mod tests { header_name: Some("Idempotency-Key".to_owned()), }), safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), } @@ -238,4 +284,42 @@ mod tests { "required idempotency needs input_field or header_name" ); } + + #[test] + fn accepts_valid_approval_policy() { + let mut config = cacheable_execution_config(); + config.approval_policy = Some(OperationApprovalPolicy { + required: true, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "Подтвердите действие".to_owned(), + confirmation_body_template: "Выполнить действие?".to_owned(), + ttl_seconds: 300, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + }); + + validate_approval_policy(&config).unwrap(); + } + + #[test] + fn rejects_invalid_approval_policy() { + let mut config = cacheable_execution_config(); + config.approval_policy = Some(OperationApprovalPolicy { + required: true, + risk_level: OperationApprovalRiskLevel::Dangerous, + confirmation_title: "".to_owned(), + confirmation_body_template: "Выполнить действие?".to_owned(), + ttl_seconds: 0, + show_payload_preview: true, + payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson, + }); + + let error = validate_approval_policy(&config).unwrap_err(); + + assert!(matches!(error, crate::error::ApiError::Validation { .. })); + assert_eq!( + error.to_string(), + "approval ttl must be between 1 and 300 seconds" + ); + } } diff --git a/apps/admin-api/tests/integration/common.rs b/apps/admin-api/tests/integration/common.rs index 0899197..691fbb2 100644 --- a/apps/admin-api/tests/integration/common.rs +++ b/apps/admin-api/tests/integration/common.rs @@ -297,6 +297,7 @@ pub(super) fn test_operation_payload(base_url: &str, name: &str) -> OperationPay response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/apps/mcp-server/tests/integration/common.rs b/apps/mcp-server/tests/integration/common.rs index a0d3f5c..420fb56 100644 --- a/apps/mcp-server/tests/integration/common.rs +++ b/apps/mcp-server/tests/integration/common.rs @@ -483,6 +483,7 @@ pub(super) fn test_operation(base_url: &str, name: &str) -> Operation +
+
+
+ + + + +
+
+
Подтверждение человеком
+
Включайте для действий, которые нельзя выполнять без явного решения пользователя.
+
+
+
+ + + +
+
+
Проверка и публикация
diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 54d7ec6..4265662 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -572,6 +572,27 @@ var TRANSLATIONS = { 'wizard.step5.execution': 'Execution settings', 'wizard.step5.exec_title': 'Request execution', 'wizard.step5.exec_subtitle': 'Timeout, retry count and authorization profile', + 'wizard.approval.title': 'Human confirmation', + 'wizard.approval.subtitle': 'Enable this for actions that must not run without an explicit user decision.', + 'wizard.approval.required_label': 'Require confirmation before execution', + 'wizard.approval.required_desc': 'The MCP client receives a pending request, and the action runs only after confirmation through a separate approval endpoint.', + 'wizard.approval.risk_level': 'Risk level', + 'wizard.approval.risk.normal': 'Normal action', + 'wizard.approval.risk.dangerous': 'Dangerous action', + 'wizard.approval.risk.financial': 'Financial action', + 'wizard.approval.risk.irreversible': 'Irreversible action', + 'wizard.approval.ttl': 'How long to wait for confirmation', + 'wizard.approval.confirmation_title': 'Confirmation title', + 'wizard.approval.confirmation_title_hint': 'This text will be shown by the external confirmation interface.', + 'wizard.approval.confirmation_body': 'Description for the user', + 'wizard.approval.confirmation_body_hint': 'Explain briefly what will happen after confirmation.', + 'wizard.approval.show_payload': 'Show request parameters', + 'wizard.approval.payload_mode': 'How to show parameters', + 'wizard.approval.payload.summary': 'Short summary', + 'wizard.approval.payload.masked_json': 'JSON with masked secrets', + 'wizard.approval.preview_label': 'Preview for confirmation UI', + 'wizard.approval.default_title': 'Confirm operation execution', + 'wizard.approval.default_body': 'Review operation parameters and confirm execution.', 'wizard.step5.security_level_title': 'Operation security', 'wizard.step5.community_security_note': '', 'wizard.step5.live_title': 'Check and publish', @@ -1396,6 +1417,27 @@ var TRANSLATIONS = { 'wizard.step5.execution': 'Параметры выполнения', 'wizard.step5.exec_title': 'Выполнение запроса', 'wizard.step5.exec_subtitle': 'Время ожидания, повторные попытки и профиль авторизации', + 'wizard.approval.title': 'Подтверждение человеком', + 'wizard.approval.subtitle': 'Включайте для действий, которые нельзя выполнять без явного решения пользователя.', + 'wizard.approval.required_label': 'Требовать подтверждение перед выполнением', + 'wizard.approval.required_desc': 'MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.', + 'wizard.approval.risk_level': 'Уровень риска', + 'wizard.approval.risk.normal': 'Обычное действие', + 'wizard.approval.risk.dangerous': 'Опасное действие', + 'wizard.approval.risk.financial': 'Финансовое действие', + 'wizard.approval.risk.irreversible': 'Необратимое действие', + 'wizard.approval.ttl': 'Сколько ждать подтверждение', + 'wizard.approval.confirmation_title': 'Заголовок подтверждения', + 'wizard.approval.confirmation_title_hint': 'Этот текст увидит внешний интерфейс подтверждения.', + 'wizard.approval.confirmation_body': 'Описание для пользователя', + 'wizard.approval.confirmation_body_hint': 'Коротко объясните, что произойдет после подтверждения.', + 'wizard.approval.show_payload': 'Показывать параметры запроса', + 'wizard.approval.payload_mode': 'Как показывать параметры', + 'wizard.approval.payload.summary': 'Краткое описание', + 'wizard.approval.payload.masked_json': 'JSON с маскированием секретов', + 'wizard.approval.preview_label': 'Предпросмотр для интерфейса подтверждения', + 'wizard.approval.default_title': 'Подтвердите выполнение операции', + 'wizard.approval.default_body': 'Проверьте параметры операции и подтвердите выполнение.', 'wizard.step5.security_level_title': 'Защита операции', 'wizard.step5.community_security_note': '', 'wizard.step5.live_title': 'Проверка и публикация', diff --git a/apps/ui/js/wizard-live.js b/apps/ui/js/wizard-live.js index f9ebfd2..8904ec7 100644 --- a/apps/ui/js/wizard-live.js +++ b/apps/ui/js/wizard-live.js @@ -27,6 +27,45 @@ function buildWizardState() { }; } +function checkedValue(id) { + var element = document.getElementById(id); + return !!(element && element.checked); +} + +function normalizeApprovalTtlSeconds(value) { + var ttl = Number(value || 300); + if (!Number.isFinite(ttl)) return 300; + return Math.max(1, Math.min(300, Math.round(ttl))); +} + +function buildApprovalPolicy() { + if (!checkedValue('approval-required')) return null; + + var title = textValue('approval-title') || tKey('wizard.approval.default_title'); + var body = textValue('approval-body') || tKey('wizard.approval.default_body'); + + return { + required: true, + risk_level: textValue('approval-risk-level') || 'normal', + confirmation_title: title, + confirmation_body_template: body, + ttl_seconds: normalizeApprovalTtlSeconds(textValue('approval-ttl-seconds')), + show_payload_preview: checkedValue('approval-show-payload-preview'), + payload_preview_mode: textValue('approval-payload-preview-mode') || 'summary', + }; +} + +function applyApprovalPolicyToExecutionConfig(config) { + var next = config || {}; + var policy = buildApprovalPolicy(); + if (policy) { + next.approval_policy = policy; + } else { + next.approval_policy = null; + } + return next; +} + function collectWizardPayload() { var name = textValue('tool-name'); if (!name) throw new Error(tKey('wizard.error.tool_name')); @@ -50,7 +89,7 @@ function collectWizardPayload() { output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []), input_mapping: buildMappingSet(inputMappingValue, 'input'), output_mapping: buildMappingSet(outputMappingValue, 'output'), - execution_config: parseExecutionConfig(textValue('tool-exec-config')), + execution_config: applyApprovalPolicyToExecutionConfig(parseExecutionConfig(textValue('tool-exec-config'))), tool_description: buildToolDescription(), wizard_state: buildWizardState(), }; @@ -126,6 +165,7 @@ function bindWizardLiveActions() { if (window.CrankWizardMapping && typeof window.CrankWizardMapping.initialize === 'function') { window.CrankWizardMapping.initialize(); } + bindApprovalPolicyControls(); bindAgentFacingPreview(); } @@ -147,6 +187,54 @@ function bindLiveAction(id, busyLabel, handler) { }); } +function setApprovalPolicyEditor(policy) { + var enabled = !!(policy && policy.required); + var required = document.getElementById('approval-required'); + if (required) required.checked = enabled; + setValue('approval-risk-level', policy && policy.risk_level ? policy.risk_level : 'normal'); + setValue('approval-ttl-seconds', policy && policy.ttl_seconds ? String(policy.ttl_seconds) : '300'); + setValue('approval-title', policy && policy.confirmation_title ? policy.confirmation_title : tKey('wizard.approval.default_title')); + setValue('approval-body', policy && policy.confirmation_body_template ? policy.confirmation_body_template : tKey('wizard.approval.default_body')); + var showPayload = document.getElementById('approval-show-payload-preview'); + if (showPayload) { + showPayload.checked = !policy || policy.show_payload_preview !== false; + } + setValue('approval-payload-preview-mode', policy && policy.payload_preview_mode ? policy.payload_preview_mode : 'summary'); + updateApprovalPolicyUi(); +} + +function updateApprovalPolicyUi() { + var enabled = checkedValue('approval-required'); + var toggle = document.getElementById('approval-required-toggle'); + var fields = document.getElementById('approval-config-fields'); + if (toggle) toggle.classList.toggle('on', enabled); + if (fields) fields.hidden = !enabled; + + var title = textValue('approval-title') || tKey('wizard.approval.default_title'); + var body = textValue('approval-body') || tKey('wizard.approval.default_body'); + setTextContent('approval-preview-title', title); + setTextContent('approval-preview-body', body); +} + +function bindApprovalPolicyControls() { + [ + 'approval-required', + 'approval-risk-level', + 'approval-ttl-seconds', + 'approval-title', + 'approval-body', + 'approval-show-payload-preview', + 'approval-payload-preview-mode', + ].forEach(function(id) { + var element = document.getElementById(id); + if (!element || element.dataset.approvalBound === 'true') return; + element.dataset.approvalBound = 'true'; + element.addEventListener('input', updateApprovalPolicyUi); + element.addEventListener('change', updateApprovalPolicyUi); + }); + updateApprovalPolicyUi(); +} + async function runWizardLiveAction(button, busyLabel, handler) { if (!button || button.dataset.busy === 'true') { return; @@ -708,4 +796,5 @@ function copyTestResponseToOutputSample() { bindWizardLiveActions: bindWizardLiveActions, updateWizardProtocolVisibility: updateWizardProtocolVisibility, renderAgentFacingPreview: renderAgentFacingPreview, + setApprovalPolicyEditor: setApprovalPolicyEditor, }; diff --git a/apps/ui/js/wizard-model.js b/apps/ui/js/wizard-model.js index 5e3b4ee..59b9706 100644 --- a/apps/ui/js/wizard-model.js +++ b/apps/ui/js/wizard-model.js @@ -429,6 +429,13 @@ function executionConfigToEditorValue(config) { return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2); } +function setApprovalPolicyFromSnapshot(config) { + if (!window.CrankWizardLive || typeof window.CrankWizardLive.setApprovalPolicyEditor !== 'function') { + return; + } + window.CrankWizardLive.setApprovalPolicyEditor(config && config.approval_policy ? config.approval_policy : null); +} + function operationSnapshot(versionDocument) { if (!versionDocument) return {}; return versionDocument.snapshot || versionDocument; @@ -487,6 +494,7 @@ function prefillWizardFromEdit(detail, versionDocument) { setValue('tool-input-mapping', mappingSetToEditorValue(snapshot.input_mapping, 'input', snapshot.protocol || detail.protocol)); setValue('tool-output-mapping', mappingSetToEditorValue(snapshot.output_mapping, 'output', snapshot.protocol || detail.protocol)); setValue('tool-exec-config', executionConfigToEditorValue(snapshot.execution_config || {})); + setApprovalPolicyFromSnapshot(snapshot.execution_config || {}); prefillWizardSamples(snapshot); if (window.CrankWizardMapping && typeof window.CrankWizardMapping.renderFromEditors === 'function') { window.CrankWizardMapping.renderFromEditors(); diff --git a/apps/ui/tests/e2e/wizard.spec.js b/apps/ui/tests/e2e/wizard.spec.js index 055d934..de42528 100644 --- a/apps/ui/tests/e2e/wizard.spec.js +++ b/apps/ui/tests/e2e/wizard.spec.js @@ -550,6 +550,15 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({ headers: {}, protocol_options: null, streaming: null, + approval_policy: { + required: true, + risk_level: 'financial', + confirmation_title: 'Подтвердите обмен валюты', + confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.', + ttl_seconds: 180, + show_payload_preview: true, + payload_preview_mode: 'masked_json', + }, }, tool_description: { title: 'Получить историю курсов за месяц', @@ -765,6 +774,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn headers: {}, protocol_options: null, streaming: null, + approval_policy: { + required: true, + risk_level: 'financial', + confirmation_title: 'Подтвердите обмен валюты', + confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.', + ttl_seconds: 180, + show_payload_preview: true, + payload_preview_mode: 'masked_json', + }, }, tool_description: { title: 'Получить последний курс', @@ -810,6 +828,14 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn await expect(page.locator('#tool-input-mapping')).toHaveValue(/query\.base/); await expect(page.locator('#tool-input-mapping')).toHaveValue(/path\.date/); await expect(page.locator('#tool-input-mapping')).toHaveValue(/transform: to_string/); + await expect(page.locator('#approval-required')).toBeChecked(); + await page.evaluate(() => window.CrankWizardShell.doGoToStep(5)); + await expect(page.locator('#approval-config-fields')).toBeVisible(); + await expect(page.locator('#approval-risk-level')).toHaveValue('financial'); + await expect(page.locator('#approval-ttl-seconds')).toHaveValue('180'); + await expect(page.locator('#approval-title')).toHaveValue('Подтвердите обмен валюты'); + await expect(page.locator('#approval-body')).toHaveValue('Проверьте валюты и подтвердите выполнение операции.'); + await expect(page.locator('#approval-payload-preview-mode')).toHaveValue('masked_json'); await page.locator('.btn-save-draft').click(); await expect.poll(() => updatePayload).not.toBeNull(); @@ -835,6 +861,15 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn headers: {}, protocol_options: null, streaming: null, + approval_policy: { + required: true, + risk_level: 'financial', + confirmation_title: 'Подтвердите обмен валюты', + confirmation_body_template: 'Проверьте валюты и подтвердите выполнение операции.', + ttl_seconds: 180, + show_payload_preview: true, + payload_preview_mode: 'masked_json', + }, }); expect(updatePayload.tool_description).toEqual({ title: 'Получить последний курс', diff --git a/crates/crank-community-mcp/tests/unit/manifest.rs b/crates/crank-community-mcp/tests/unit/manifest.rs index d4957d4..ef98e0b 100644 --- a/crates/crank-community-mcp/tests/unit/manifest.rs +++ b/crates/crank-community-mcp/tests/unit/manifest.rs @@ -106,6 +106,7 @@ fn operation() -> RegistryOperation { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/crates/crank-core/src/lib.rs b/crates/crank-core/src/lib.rs index dece106..e896341 100644 --- a/crates/crank-core/src/lib.rs +++ b/crates/crank-core/src/lib.rs @@ -41,9 +41,10 @@ pub mod domain { }; pub use crate::operation::{ ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, - IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy, - OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, - ToolDescription, ToolExample, WizardState, + IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode, + OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass, + OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, + Samples, Target, ToolDescription, ToolExample, WizardState, }; pub use crate::protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use crate::secret::{Secret, SecretKind, SecretStatus, SecretVersion}; @@ -128,9 +129,10 @@ pub use observability::{ }; pub use operation::{ ConfigExport, ConfirmationPolicy, ExecutionConfig, GeneratedDraft, GeneratedDraftStatus, - IdempotencyMode, IdempotencyPolicy, Operation, OperationSafetyClass, OperationSafetyPolicy, - OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, Target, - ToolDescription, ToolExample, WizardState, + IdempotencyMode, IdempotencyPolicy, Operation, OperationApprovalPayloadPreviewMode, + OperationApprovalPolicy, OperationApprovalRiskLevel, OperationSafetyClass, + OperationSafetyPolicy, OperationStatus, ResponseCachePolicy, RestTarget, RetryPolicy, Samples, + Target, ToolDescription, ToolExample, WizardState, }; pub use protocol::{AuthKind, ExportMode, HttpMethod, Protocol}; pub use secret::{Secret, SecretKind, SecretStatus, SecretVersion}; diff --git a/crates/crank-core/src/operation.rs b/crates/crank-core/src/operation.rs index 8af7f07..b06b736 100644 --- a/crates/crank-core/src/operation.rs +++ b/crates/crank-core/src/operation.rs @@ -103,6 +103,35 @@ pub struct OperationSafetyPolicy { pub confirmation: Option, } +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationApprovalRiskLevel { + #[default] + Normal, + Dangerous, + Financial, + Irreversible, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum OperationApprovalPayloadPreviewMode { + #[default] + Summary, + MaskedJson, +} + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct OperationApprovalPolicy { + pub required: bool, + pub risk_level: OperationApprovalRiskLevel, + pub confirmation_title: String, + pub confirmation_body_template: String, + pub ttl_seconds: u32, + pub show_payload_preview: bool, + pub payload_preview_mode: OperationApprovalPayloadPreviewMode, +} + #[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] pub struct ExecutionConfig { pub timeout_ms: u64, @@ -115,6 +144,8 @@ pub struct ExecutionConfig { #[serde(skip_serializing_if = "Option::is_none")] pub safety: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub approval_policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub auth_profile_ref: Option, #[serde(default, skip_serializing_if = "BTreeMap::is_empty")] pub headers: BTreeMap, @@ -411,6 +442,7 @@ updated_at: 2026-03-25T08:10:00Z response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: Some(AuthProfileId::new("auth_01")), headers: BTreeMap::new(), }, diff --git a/crates/crank-import/src/rest/payload.rs b/crates/crank-import/src/rest/payload.rs index 1bdca5e..8a8e9e2 100644 --- a/crates/crank-import/src/rest/payload.rs +++ b/crates/crank-import/src/rest/payload.rs @@ -231,6 +231,7 @@ fn default_execution_config() -> ExecutionConfig { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), } diff --git a/crates/crank-runtime/tests/integration/confirmation.rs b/crates/crank-runtime/tests/integration/confirmation.rs index 1bf5c1c..6cba2c6 100644 --- a/crates/crank-runtime/tests/integration/confirmation.rs +++ b/crates/crank-runtime/tests/integration/confirmation.rs @@ -160,6 +160,7 @@ fn destructive_delete_operation() -> Operation { class: OperationSafetyClass::Destructive, confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }), }), + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/crates/crank-runtime/tests/integration/idempotency.rs b/crates/crank-runtime/tests/integration/idempotency.rs index 1cc175f..141b2f0 100644 --- a/crates/crank-runtime/tests/integration/idempotency.rs +++ b/crates/crank-runtime/tests/integration/idempotency.rs @@ -146,6 +146,7 @@ fn idempotent_post_operation() -> Operation { header_name: Some("Idempotency-Key".to_owned()), }), safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), }, diff --git a/crates/crank-runtime/tests/integration/no_input_get.rs b/crates/crank-runtime/tests/integration/no_input_get.rs index 5396356..1a9b99d 100644 --- a/crates/crank-runtime/tests/integration/no_input_get.rs +++ b/crates/crank-runtime/tests/integration/no_input_get.rs @@ -71,6 +71,7 @@ fn no_input_get_operation() -> Operation { response_cache: None, idempotency: None, safety: None, + approval_policy: None, auth_profile_ref: None, headers: BTreeMap::new(), },