Compare commits

...

5 Commits

Author SHA1 Message Date
github-ops 78d3052a61 Document human approval flow
CI / Rust Checks (push) Successful in 1h25m31s
CI / UI Checks (push) Successful in 6s
CI / Deployment Manifests (push) Successful in 2s
CI / Deploy (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
2026-06-24 11:43:34 +00:00
github-ops 0d193b84dd Gate MCP tool calls on human approval 2026-06-24 11:42:16 +00:00
github-ops 8a1cc1746f Add approval request HTTP surface 2026-06-24 11:39:39 +00:00
github-ops d83ab541d9 Add operation approval policy editor 2026-06-24 10:45:09 +00:00
github-ops 5922aea68f Split MCP and approval agent keys 2026-06-24 10:31:52 +00:00
41 changed files with 2075 additions and 126 deletions
+12 -2
View File
@@ -1,8 +1,8 @@
use crank_core::{
AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft,
InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus,
PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod, WizardState, WorkspaceId,
WorkspaceStatus,
PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
@@ -211,7 +211,17 @@ pub struct AgentMutationResult {
#[derive(Clone, Debug, Deserialize)]
pub struct PlatformApiKeyPayload {
pub name: String,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub scopes: Vec<PlatformApiKeyScope>,
#[serde(default)]
pub expires_at: Option<String>,
#[serde(default)]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[derive(Clone, Debug, Serialize)]
+1
View File
@@ -175,6 +175,7 @@ mod tests {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+4 -1
View File
@@ -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(())
+56 -3
View File
@@ -1,7 +1,10 @@
use crank_core::{AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, WorkspaceId};
use crank_core::{
AgentId, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, WorkspaceId,
};
use crank_registry::{CreatePlatformApiKeyRequest, PlatformApiKeyRecord};
use serde_json::json;
use time::OffsetDateTime;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use tracing::instrument;
use crate::{
@@ -53,7 +56,19 @@ impl AdminService {
)
})?;
let secret = generate_access_secret("crk");
validate_platform_api_key_payload(&payload)?;
let expires_at = match payload.expires_at.as_deref() {
Some(value) => Some(
OffsetDateTime::parse(value, &Rfc3339)
.map_err(|_| ApiError::validation("expires_at must be RFC3339 timestamp"))?,
),
None => None,
};
let secret = generate_access_secret(match payload.key_kind {
PlatformApiKeyKind::McpClient => "crk",
PlatformApiKeyKind::Approval => "crk_appr",
});
let api_key = PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(new_prefixed_id("pk")),
@@ -61,10 +76,13 @@ impl AdminService {
agent_id: Some(agent_id.clone()),
name: payload.name,
prefix: secret.chars().take(16).collect(),
key_kind: payload.key_kind,
scopes: payload.scopes,
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::now_utc(),
last_used_at: None,
expires_at,
allowed_origins: payload.allowed_origins,
},
};
@@ -109,3 +127,38 @@ impl AdminService {
Ok(())
}
}
fn validate_platform_api_key_payload(payload: &PlatformApiKeyPayload) -> Result<(), ApiError> {
if payload.name.trim().is_empty() {
return Err(ApiError::validation("key name is required"));
}
if payload.scopes.is_empty() {
return Err(ApiError::validation("at least one key scope is required"));
}
let valid = payload.scopes.iter().all(|scope| match payload.key_kind {
PlatformApiKeyKind::McpClient => matches!(
scope,
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy
),
PlatformApiKeyKind::Approval => matches!(
scope,
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending
),
});
if !valid {
return Err(ApiError::validation(
"key scopes do not match selected key kind",
));
}
if payload.key_kind == PlatformApiKeyKind::Approval && payload.allowed_origins.len() > 20 {
return Err(ApiError::validation(
"approval key can contain at most 20 allowed origins",
));
}
Ok(())
}
+6 -2
View File
@@ -2,8 +2,8 @@ use std::collections::BTreeMap;
use crank_core::{
AgentId, InvocationLevel, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationSecurityLevel, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, Target,
WizardState, WorkspaceId,
OperationSecurityLevel, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, Target, WizardState, WorkspaceId,
};
use crank_mapping::{JsonPathRoot, infer_mapping_from_samples};
use crank_mapping::{MappingRule, MappingSet};
@@ -162,7 +162,10 @@ impl AdminService {
agent_id,
PlatformApiKeyPayload {
name: name.to_owned(),
key_kind: PlatformApiKeyKind::McpClient,
scopes,
expires_at: None,
allowed_origins: Vec::new(),
},
)
.await?
@@ -384,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(),
},
+1
View File
@@ -191,6 +191,7 @@ impl AdminService {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -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"
);
}
}
@@ -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(),
},
@@ -153,6 +153,7 @@ async fn manages_agent_platform_api_keys() {
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-primary",
"key_kind": "mcp_client",
"scopes": ["read", "write"]
}))
.send()
@@ -164,6 +165,31 @@ async fn manages_agent_platform_api_keys() {
.as_str()
.unwrap()
.to_owned();
let created_approval_key = assert_success_json(
client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "sales-routing-approver",
"key_kind": "approval",
"scopes": ["approve", "deny"],
"allowed_origins": ["https://client.example.test"]
}))
.send()
.await
.unwrap(),
)
.await;
let invalid_mixed_scope_status = client
.post(format!("{base_url}/agents/{agent_id}/platform-api-keys"))
.json(&json!({
"name": "invalid-mixed-scope",
"key_kind": "approval",
"scopes": ["read", "approve"]
}))
.send()
.await
.unwrap()
.status();
let listed_keys = assert_success_json(
client
@@ -192,14 +218,27 @@ async fn manages_agent_platform_api_keys() {
.status();
assert_eq!(created_key["api_key"]["api_key"]["agent_id"], agent_id);
assert_eq!(created_key["api_key"]["api_key"]["key_kind"], "mcp_client");
assert_eq!(
created_approval_key["api_key"]["api_key"]["key_kind"],
"approval"
);
assert!(
created_approval_key["secret"]
.as_str()
.unwrap()
.starts_with("crk_appr_")
);
assert_eq!(
created_approval_key["api_key"]["api_key"]["allowed_origins"],
json!(["https://client.example.test"])
);
assert_eq!(invalid_mixed_scope_status, reqwest::StatusCode::BAD_REQUEST);
assert_eq!(
listed_keys["items"][0]["api_key"]["agent_id"],
json!(agent_id)
);
assert_eq!(
listed_keys["items"][0]["api_key"]["name"],
"sales-routing-primary"
);
assert_eq!(listed_keys["items"].as_array().unwrap().len(), 2);
assert!(created_key["secret"].as_str().unwrap().starts_with("crk_"));
assert_eq!(revoke_status, reqwest::StatusCode::NO_CONTENT);
assert_eq!(delete_status, reqwest::StatusCode::NO_CONTENT);
@@ -17,14 +17,17 @@ use axum::{
};
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
ApprovalRequestId, ApprovalRequestStatus, ExecutionConfig, HttpMethod, Operation,
OperationApprovalPayloadPreviewMode, OperationApprovalPolicy, OperationApprovalRiskLevel,
OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
CreateAgentRequest, CreatePlatformApiKeyRequest, ListInvocationLogsQuery, PostgresRegistry,
PublishAgentRequest, PublishRequest, SaveAgentBindingsRequest,
CreateAgentRequest, CreateApprovalRequest, CreatePlatformApiKeyRequest,
ListInvocationLogsQuery, PostgresRegistry, PublishAgentRequest, PublishRequest,
SaveAgentBindingsRequest,
};
use crank_runtime::{
InMemoryCoordinationStateStore, RequestRateLimitConfig, RequestRateLimiter, RuntimeExecutor,
@@ -476,6 +479,248 @@ async fn rejects_initialize_without_platform_api_key() {
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn rejects_initialize_with_approval_platform_api_key() {
let registry = test_registry().await;
publish_agent_with_bindings(&registry, "sales-approval-key", vec![]).await;
let api_key =
create_approval_platform_api_key(&registry, "sales-approval-key", "approval-only").await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let response = client
.post(agent_mcp_url(&base_url, "sales-approval-key"))
.header(header::ACCEPT, "application/json, text/event-stream")
.header(header::AUTHORIZATION, format!("Bearer {api_key}"))
.json(&json!({
"jsonrpc": "2.0",
"id": 1,
"method": "initialize",
"params": {
"protocolVersion": "2025-11-25"
}
}))
.send()
.await
.unwrap();
assert_eq!(response.status(), reqwest::StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn approval_key_lists_and_decides_pending_requests() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let operation = test_operation(&upstream_base_url, "crm_human_approval");
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
publish_agent_with_bindings(
&registry,
"sales-human-approval",
vec![binding_for_operation(&operation)],
)
.await;
let approval = ApprovalRequest {
id: ApprovalRequestId::new("approval_mcp_01"),
workspace_id: test_workspace_id(),
agent_id: test_agent_id("sales-human-approval"),
operation_id: operation.id.clone(),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
confirmation_title: "Подтвердите создание лида".to_owned(),
confirmation_body: "Проверьте email перед отправкой в CRM.".to_owned(),
request_payload: json!({"email": "ada@example.com"}),
response_payload: None,
created_at: OffsetDateTime::now_utc(),
expires_at: OffsetDateTime::now_utc() + time::Duration::minutes(5),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
let approval_key =
create_approval_platform_api_key(&registry, "sales-human-approval", "approval-http").await;
let mcp_key = create_platform_api_key(
&registry,
"sales-human-approval",
"mcp-human-approval",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let base_url = spawn_mcp_server(build_test_app(
registry.clone(),
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let approvals_url = format!(
"{}/approvals",
agent_mcp_url(&base_url, "sales-human-approval")
);
let rejected = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {mcp_key}"))
.send()
.await
.unwrap();
assert_eq!(rejected.status(), reqwest::StatusCode::UNAUTHORIZED);
let pending = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap();
assert_eq!(pending.status(), reqwest::StatusCode::OK);
let pending_body = pending.json::<Value>().await.unwrap();
assert_eq!(pending_body["items"].as_array().unwrap().len(), 1);
assert_eq!(
pending_body["items"][0]["approval"]["confirmation_title"],
"Подтвердите создание лида"
);
let approve_url = format!(
"{}/approvals/{}/approve",
agent_mcp_url(&base_url, "sales-human-approval"),
approval.id
);
let approved = client
.post(&approve_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.json(&json!({ "approve": "yes", "note": "confirmed by test" }))
.send()
.await
.unwrap();
assert_eq!(approved.status(), reqwest::StatusCode::OK);
let approved_body = approved.json::<Value>().await.unwrap();
assert_eq!(
approved_body["approval"]["status"],
Value::String("approved".to_owned())
);
let pending_after = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert!(pending_after["items"].as_array().unwrap().is_empty());
}
#[tokio::test]
async fn tool_call_with_approval_policy_creates_pending_request() {
let registry = test_registry().await;
let upstream_base_url = spawn_upstream_server().await;
let mut operation = test_operation(&upstream_base_url, "crm_requires_human_approval");
operation.execution_config.approval_policy = Some(OperationApprovalPolicy {
required: true,
risk_level: OperationApprovalRiskLevel::Dangerous,
confirmation_title: "Подтвердите создание лида".to_owned(),
confirmation_body_template: "Проверьте email перед отправкой в CRM.".to_owned(),
ttl_seconds: 300,
show_payload_preview: true,
payload_preview_mode: OperationApprovalPayloadPreviewMode::MaskedJson,
});
registry
.create_operation(&test_workspace_id(), &operation, Some("alice"))
.await
.unwrap();
registry
.publish_operation(PublishRequest {
workspace_id: &test_workspace_id(),
operation_id: &operation.id,
version: 1,
published_at: &OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
published_by: Some("alice"),
})
.await
.unwrap();
publish_agent_for_operation(&registry, &operation, "sales-gated").await;
let api_key = create_platform_api_key(
&registry,
"sales-gated",
"mcp-gated",
&[PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
)
.await;
let approval_key =
create_approval_platform_api_key(&registry, "sales-gated", "approval-gated").await;
let base_url = spawn_mcp_server(build_test_app(
registry,
Duration::from_millis(0),
Some("https://crank.example.com".to_owned()),
))
.await;
let client = reqwest::Client::new();
let mcp_url = agent_mcp_url(&base_url, "sales-gated");
let initialized_session = initialize_session(&client, &mcp_url, &api_key).await;
let tool_result = post_jsonrpc(
&client,
&mcp_url,
&api_key,
Some(&initialized_session),
json!({
"jsonrpc": "2.0",
"id": 9,
"method": "tools/call",
"params": {
"name": "crm_requires_human_approval",
"arguments": {
"email": "ada@example.com"
}
}
}),
)
.await;
assert_eq!(
tool_result["result"]["structuredContent"]["status"],
"approval_required"
);
assert_eq!(tool_result["result"]["isError"], false);
let approval_id = tool_result["result"]["structuredContent"]["approval_id"]
.as_str()
.unwrap();
assert!(approval_id.starts_with("approval_"));
let approvals_url = format!("{}/approvals", agent_mcp_url(&base_url, "sales-gated"));
let pending = client
.get(&approvals_url)
.header(header::AUTHORIZATION, format!("Bearer {approval_key}"))
.send()
.await
.unwrap()
.json::<Value>()
.await
.unwrap();
assert_eq!(pending["items"].as_array().unwrap().len(), 1);
assert_eq!(pending["items"][0]["approval"]["id"], approval_id);
assert_eq!(
pending["items"][0]["approval"]["request_payload"]["email"],
"ada@example.com"
);
}
#[tokio::test]
async fn rejects_tool_call_with_read_only_platform_api_key() {
let registry = test_registry().await;
+47 -3
View File
@@ -16,8 +16,9 @@ use axum::{
use base64::{Engine as _, engine::general_purpose::URL_SAFE_NO_PAD};
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ExecutionConfig, HttpMethod,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription, WorkspaceId,
Operation, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind,
PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, RestTarget, Target, ToolDescription,
WorkspaceId,
};
use crank_mapping::{MappingRule, MappingSet};
use crank_registry::{
@@ -236,17 +237,59 @@ pub(super) async fn create_platform_api_key(
name: &str,
scopes: &[PlatformApiKeyScope],
) -> String {
let secret = format!("crk_{}_{}", name, uuid::Uuid::now_v7().simple());
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::McpClient,
scopes,
"crk",
)
.await
}
pub(super) async fn create_approval_platform_api_key(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
) -> String {
create_platform_api_key_with_kind(
registry,
agent_slug,
name,
PlatformApiKeyKind::Approval,
&[
PlatformApiKeyScope::ReadPending,
PlatformApiKeyScope::Approve,
PlatformApiKeyScope::Deny,
],
"crk_appr",
)
.await
}
async fn create_platform_api_key_with_kind(
registry: &PostgresRegistry,
agent_slug: &str,
name: &str,
key_kind: PlatformApiKeyKind,
scopes: &[PlatformApiKeyScope],
prefix: &str,
) -> String {
let secret = format!("{prefix}_{}_{}", name, uuid::Uuid::now_v7().simple());
let api_key = PlatformApiKey {
id: PlatformApiKeyId::new(format!("pk_{name}")),
workspace_id: test_workspace_id(),
agent_id: Some(test_agent_id(agent_slug)),
key_kind,
name: name.to_owned(),
prefix: secret.chars().take(16).collect(),
scopes: scopes.to_vec(),
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-26T10:00:00Z", &Rfc3339).unwrap(),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
registry
@@ -444,6 +487,7 @@ pub(super) fn test_operation(base_url: &str, name: &str) -> Operation<Schema, Ma
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+56
View File
@@ -1058,6 +1058,62 @@
.toggle-label { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.toggle-desc { font-size: 11.5px; color: var(--text-muted); margin-top: 1px; }
.approval-toggle-row {
position: relative;
}
.approval-toggle-input {
position: absolute;
opacity: 0;
pointer-events: none;
}
.approval-config-fields {
display: grid;
gap: 16px;
padding: 16px;
border: 1px solid var(--border-subtle);
border-radius: 10px;
background: rgba(13, 17, 23, 0.42);
}
.approval-preview-pill {
align-self: end;
min-height: 38px;
justify-content: center;
}
.approval-preview-card {
display: grid;
gap: 6px;
padding: 14px 16px;
border: 1px solid rgba(47, 129, 247, 0.28);
border-radius: 10px;
background:
linear-gradient(135deg, rgba(47, 129, 247, 0.12), rgba(35, 134, 54, 0.06)),
var(--bg-overlay);
}
.approval-preview-eyebrow {
font-size: 11px;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--accent);
}
.approval-preview-title {
font-size: 14px;
font-weight: 700;
color: var(--text-primary);
}
.approval-preview-body {
font-size: 12.5px;
line-height: 1.6;
color: var(--text-secondary);
}
/* ══════════════════════════════════════════════════
BOTTOM ACTION BAR — frosted dark glass
══════════════════════════════════════════════════ */
+58 -21
View File
@@ -23,9 +23,48 @@
.scope-checkbox-name { font-size: 13px; font-weight: 500; color: var(--text-primary); }
.scope-checkbox-desc { font-size: 11.5px; color: var(--text-muted); }
.keys-card-list { display: none; }
.key-kind-tabs {
display: inline-flex;
gap: 4px;
padding: 4px;
border: 1px solid var(--border);
border-radius: var(--radius-lg);
background: var(--bg-overlay);
}
.key-kind-tab {
border: 0;
border-radius: var(--radius);
background: transparent;
color: var(--text-secondary);
padding: 7px 12px;
font-size: 13px;
font-weight: 600;
}
.key-kind-tab.active {
background: var(--accent);
color: #fff;
}
.approval-warning-callout {
display: flex;
gap: 10px;
padding: 12px 14px;
border: 1px solid var(--amber-border);
border-radius: var(--radius-lg);
background: var(--amber-bg);
color: var(--text-primary);
font-size: 13px;
line-height: 1.55;
}
.approval-warning-callout svg {
color: var(--amber);
flex: 0 0 auto;
margin-top: 2px;
}
@media (max-width: 720px) {
#keys-table-wrap { display: none; }
.keys-card-list { display: grid; }
.key-kind-tabs { width: 100%; }
.key-kind-tab { flex: 1; }
}
</style>
<script src="%CRANK_BUNDLE_PROTECTED_CORE%"></script>
@@ -102,7 +141,7 @@
<div class="page-header-actions">
<button class="btn-primary" id="btn-create-key" type="button">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
<span data-i18n="apikeys.new">Create key</span>
<span id="btn-create-key-label" data-i18n="apikeys.new">Create key</span>
</button>
</div>
</div>
@@ -118,6 +157,16 @@
</div>
</div>
<div class="section-card">
<div class="section-card-body" style="display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;">
<div class="key-kind-tabs" role="tablist" aria-label="Key type">
<button class="key-kind-tab active" id="key-kind-mcp-client" type="button" data-key-kind="mcp_client" data-i18n="apikeys.kind.mcp">MCP clients</button>
<button class="key-kind-tab" id="key-kind-approval" type="button" data-key-kind="approval" data-i18n="apikeys.kind.approval">Approvals</button>
</div>
<div class="field-hint" id="key-kind-hint" style="max-width:560px;margin:0;"></div>
</div>
</div>
<div class="section-card">
<div class="section-card-header">
<div>
@@ -170,25 +219,7 @@
<div class="section-card-header">
<div class="section-card-title" data-i18n="apikeys.scope_ref">Access reference</div>
</div>
<div class="section-card-body" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;display:flex;align-items:center;gap:6px;">
<span class="badge badge-scope">read</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.read">Initialize MCP sessions, ping the server, and list tools for a workspace agent.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">write</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.write">Execute `tools/call` requests against published agent toolsets.</div>
</div>
<div>
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">deploy</span>
</div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;" data-i18n="apikeys.scope.deploy">Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.</div>
</div>
<div class="section-card-body" id="scope-reference-grid" style="display:grid;grid-template-columns:repeat(3,1fr);gap:12px;">
</div>
</div>
@@ -198,7 +229,7 @@
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal-header">
<span class="modal-title" data-i18n="apikeys.modal.title">Create agent key</span>
<span class="modal-title" id="modal-create-title" data-i18n="apikeys.modal.title">Create agent key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
@@ -211,6 +242,12 @@
<input class="field-input" id="new-key-name" type="text" data-i18n-ph="apikeys.modal.name_placeholder" placeholder="e.g. Production, CI pipeline" autocomplete="off">
<div class="field-hint" data-i18n="apikeys.modal.name_hint">A descriptive label to identify the key. Only visible to admins.</div>
</div>
<div class="approval-warning-callout" id="approval-key-warning" hidden>
<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div data-i18n="apikeys.approval.warning">Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.</div>
</div>
<div class="field-group" style="margin-bottom:0;">
<label class="field-label" data-i18n="apikeys.modal.scopes">Access</label>
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
+79
View File
@@ -126,6 +126,85 @@ tls:
</div>
</div>
<div class="config-card approval-gate-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2l5 2v4c0 3-2 5-5 6-3-1-5-3-5-6V4l5-2z"/>
<path d="M6 8l1.4 1.4L10.5 6"/>
</svg>
</div>
<div>
<div class="config-card-title" data-i18n="wizard.approval.title">Подтверждение человеком</div>
<div class="config-card-subtitle" data-i18n="wizard.approval.subtitle">Включайте для действий, которые нельзя выполнять без явного решения пользователя.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<label class="toggle-row approval-toggle-row" for="approval-required">
<span id="approval-required-toggle" class="toggle" aria-hidden="true"></span>
<span class="toggle-text">
<span class="toggle-label" data-i18n="wizard.approval.required_label">Требовать подтверждение перед выполнением</span>
<span class="toggle-desc" data-i18n="wizard.approval.required_desc">MCP клиент получит ожидающий запрос, а действие выполнится только после подтверждения через отдельный эндпоинт подтверждения.</span>
</span>
<input id="approval-required" type="checkbox" class="approval-toggle-input">
</label>
<div id="approval-config-fields" class="approval-config-fields" hidden>
<div class="form-row">
<div class="form-group">
<label class="form-label" for="approval-risk-level" data-i18n="wizard.approval.risk_level">Уровень риска</label>
<select id="approval-risk-level" class="form-select">
<option value="normal" data-i18n="wizard.approval.risk.normal">Обычное действие</option>
<option value="dangerous" data-i18n="wizard.approval.risk.dangerous">Опасное действие</option>
<option value="financial" data-i18n="wizard.approval.risk.financial">Финансовое действие</option>
<option value="irreversible" data-i18n="wizard.approval.risk.irreversible">Необратимое действие</option>
</select>
</div>
<div class="form-group">
<label class="form-label" for="approval-ttl-seconds" data-i18n="wizard.approval.ttl">Сколько ждать подтверждение</label>
<select id="approval-ttl-seconds" class="form-select">
<option value="60">1 минута</option>
<option value="180">3 минуты</option>
<option value="300" selected>5 минут</option>
</select>
</div>
</div>
<div class="form-group">
<label class="form-label" for="approval-title" data-i18n="wizard.approval.confirmation_title">Заголовок подтверждения</label>
<input id="approval-title" class="form-input" type="text" autocomplete="off" placeholder="Подтвердите выполнение операции">
<div class="form-hint" data-i18n="wizard.approval.confirmation_title_hint">Этот текст увидит внешний интерфейс подтверждения.</div>
</div>
<div class="form-group">
<label class="form-label" for="approval-body" data-i18n="wizard.approval.confirmation_body">Описание для пользователя</label>
<textarea id="approval-body" class="form-textarea" rows="4" placeholder="Проверьте параметры операции и подтвердите выполнение."></textarea>
<div class="form-hint" data-i18n="wizard.approval.confirmation_body_hint">Коротко объясните, что произойдет после подтверждения.</div>
</div>
<div class="form-row">
<label class="checkbox-pill approval-preview-pill">
<input id="approval-show-payload-preview" type="checkbox" checked>
<span data-i18n="wizard.approval.show_payload">Показывать параметры запроса</span>
</label>
<div class="form-group">
<label class="form-label" for="approval-payload-preview-mode" data-i18n="wizard.approval.payload_mode">Как показывать параметры</label>
<select id="approval-payload-preview-mode" class="form-select">
<option value="summary" data-i18n="wizard.approval.payload.summary">Краткое описание</option>
<option value="masked_json" data-i18n="wizard.approval.payload.masked_json">JSON с маскированием секретов</option>
</select>
</div>
</div>
<div class="approval-preview-card">
<div class="approval-preview-eyebrow" data-i18n="wizard.approval.preview_label">Предпросмотр для интерфейса подтверждения</div>
<div class="approval-preview-title" id="approval-preview-title">Подтвердите выполнение операции</div>
<div class="approval-preview-body" id="approval-preview-body">Проверьте параметры операции и подтвердите выполнение.</div>
</div>
</div>
</div>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Проверка и публикация</span>
<div class="section-divider-line"></div>
+82 -8
View File
@@ -2,14 +2,21 @@ var KEYS = [];
var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var activeKeyKind = 'mcp_client';
var selectedScopes = new Set(['read']);
var search = '';
var SCOPES = ['read', 'write', 'deploy'];
var SCOPES_BY_KIND = {
mcp_client: ['read', 'write', 'deploy'],
approval: ['read_pending', 'approve', 'deny'],
};
var SCOPE_DESC = {
read: 'apikeys.scope.read',
write: 'apikeys.scope.write',
deploy: 'apikeys.scope.deploy',
approve: 'apikeys.scope.approve',
deny: 'apikeys.scope.deny',
read_pending: 'apikeys.scope.read_pending',
};
function tKey(key) {
@@ -35,6 +42,7 @@ function mapKeyRecord(record) {
return {
id: apiKey.id,
agentId: apiKey.agent_id || null,
keyKind: apiKey.key_kind || 'mcp_client',
name: apiKey.name,
prefix: apiKey.prefix || '',
scopes: apiKey.scopes || [],
@@ -173,6 +181,7 @@ async function deleteKey(id) {
async function createKey(name, scopes) {
var created = await window.CrankApi.createAgentPlatformApiKey(currentWorkspaceId, currentAgentId, {
name: name,
key_kind: activeKeyKind,
scopes: scopes,
});
return {
@@ -221,13 +230,19 @@ function setCreateButtonState() {
var button = document.getElementById('btn-create-key');
if (!button) return;
button.disabled = !currentAgentId;
var label = document.getElementById('btn-create-key-label');
if (label) {
label.textContent = activeKeyKind === 'approval'
? tKey('apikeys.new_approval')
: tKey('apikeys.new_mcp');
}
}
function renderScopes() {
var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = '';
SCOPES.forEach(function(scope) {
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input');
input.dataset.scope = scope;
@@ -242,11 +257,49 @@ function renderScopes() {
});
}
function renderKeyKindTabs() {
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
var kind = button.dataset.keyKind;
button.classList.toggle('active', kind === activeKeyKind);
button.setAttribute('aria-selected', kind === activeKeyKind ? 'true' : 'false');
});
var hint = document.getElementById('key-kind-hint');
if (hint) {
hint.textContent = activeKeyKind === 'approval'
? tKey('apikeys.kind.approval_hint')
: tKey('apikeys.kind.mcp_hint');
}
renderScopeReference();
setCreateButtonState();
}
function renderScopeReference() {
var grid = document.getElementById('scope-reference-grid');
if (!grid) return;
grid.innerHTML = '';
(SCOPES_BY_KIND[activeKeyKind] || []).forEach(function(scope) {
var item = document.createElement('div');
var title = document.createElement('div');
title.style.cssText = 'font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;';
var badge = document.createElement('span');
badge.className = 'badge badge-scope';
badge.textContent = scope;
title.appendChild(badge);
var description = document.createElement('div');
description.style.cssText = 'font-size:12px;color:var(--text-muted);line-height:1.55;';
description.textContent = tKey(SCOPE_DESC[scope]);
item.appendChild(title);
item.appendChild(description);
grid.appendChild(item);
});
}
function renderTable(errorMessage) {
var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody');
var cardList = document.getElementById('keys-card-list');
var rows = KEYS.filter(function(key) {
var visibleKeys = KEYS.filter(function(key) { return key.keyKind === activeKeyKind; });
var rows = visibleKeys.filter(function(key) {
return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
});
var subtitle = document.getElementById('keys-summary-subtitle');
@@ -257,8 +310,8 @@ function renderTable(errorMessage) {
}
if (subtitle) {
var active = KEYS.filter(function(key) { return key.status === 'active'; }).length;
var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length;
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
@@ -282,7 +335,7 @@ function renderTable(errorMessage) {
td.colSpan = 7;
td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);';
td.textContent = currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
@@ -349,7 +402,7 @@ function renderKeyCards(rows, errorMessage) {
cardList.appendChild(
buildKeyCardMessage(
currentAgentId
? (KEYS.length ? tKey('apikeys.empty.search') : tKey('apikeys.empty.none'))
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint'),
false
)
@@ -424,6 +477,12 @@ function buildKeyCardMessage(text, isError) {
return card;
}
function emptyTextForKind() {
return activeKeyKind === 'approval'
? tKey('apikeys.empty.approval')
: tKey('apikeys.empty.none');
}
function buildMetaItem(labelKey, valueText) {
var item = document.createElement('div');
item.className = 'resource-meta-item';
@@ -456,7 +515,11 @@ function openModal() {
document.getElementById('modal-footer-create').hidden = false;
document.getElementById('modal-footer-done').hidden = true;
document.getElementById('new-key-name').value = '';
selectedScopes = new Set(['read']);
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
? tKey('apikeys.modal.title_approval')
: tKey('apikeys.modal.title_mcp');
document.getElementById('approval-key-warning').hidden = activeKeyKind !== 'approval';
renderScopes();
modal.classList.add('open');
setTimeout(function() {
@@ -554,6 +617,16 @@ document.getElementById('agent-select').addEventListener('change', async functio
await loadKeys();
});
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
button.addEventListener('click', function() {
activeKeyKind = this.dataset.keyKind || 'mcp_client';
search = '';
document.getElementById('key-search').value = '';
renderKeyKindTabs();
renderTable();
});
});
function copyPrefix(prefix) {
if (navigator.clipboard) {
navigator.clipboard.writeText(prefix).catch(function() {});
@@ -564,6 +637,7 @@ function copyPrefix(prefix) {
}
document.addEventListener('DOMContentLoaded', async function() {
renderKeyKindTabs();
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
await loadKeys();
window.addEventListener('crank:workspacechange', function() {
+68
View File
@@ -95,6 +95,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Agent Keys',
'apikeys.subtitle': 'These keys connect an MCP client to the MCP server and are issued for a specific agent.',
'apikeys.new': 'Create key',
'apikeys.new_mcp': 'Create MCP client key',
'apikeys.new_approval': 'Create approval key',
'apikeys.kind.mcp': 'MCP clients',
'apikeys.kind.approval': 'Approvals',
'apikeys.kind.mcp_hint': 'MCP client keys connect an agent to tools/list and tools/call.',
'apikeys.kind.approval_hint': 'Approval keys are used only by an external human confirmation interface.',
'apikeys.agent.title': 'Agent selection',
'apikeys.agent.subtitle': 'Select the AI agent this key is issued for.',
'apikeys.agent.label': 'AI agent',
@@ -118,7 +124,12 @@ var TRANSLATIONS = {
'apikeys.scope.read': 'Initialize MCP sessions, ping the server, and list tools for the selected AI agent.',
'apikeys.scope.write': 'Execute `tools/call` requests against published agent toolsets.',
'apikeys.scope.deploy': 'Reserved for deploy-scoped automation. Today it also permits MCP read/write flows.',
'apikeys.scope.approve': 'Confirm a pending human approval request for this agent.',
'apikeys.scope.deny': 'Reject a pending human approval request for this agent.',
'apikeys.scope.read_pending': 'Read pending human approval requests for this agent.',
'apikeys.modal.title': 'Create agent key',
'apikeys.modal.title_mcp': 'Create MCP client key',
'apikeys.modal.title_approval': 'Create approval key',
'apikeys.modal.name': 'Key name',
'apikeys.modal.name_hint': 'A descriptive label to identify the key. Only visible to admins.',
'apikeys.modal.name_placeholder': 'e.g. Production, CI pipeline',
@@ -133,6 +144,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Revoked',
'apikeys.last_used.never': 'Never',
'apikeys.empty.none': 'No API keys yet',
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
'apikeys.empty.search': 'No keys match your search',
'apikeys.loading': 'Loading…',
'apikeys.error.api': 'Workspace or API is unavailable',
@@ -160,6 +172,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Revoke key',
'apikeys.action.delete': 'Delete',
'apikeys.creating': 'Creating…',
'apikeys.approval.warning': 'Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.',
// Secrets page
'secrets.title': 'Secrets',
@@ -560,6 +573,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',
@@ -905,6 +939,12 @@ var TRANSLATIONS = {
'apikeys.title': 'Ключи агентов',
'apikeys.subtitle': 'Эти ключи используются для подключения MCP клиента к MCP серверу и выдаются на конкретного агента.',
'apikeys.new': 'Создать ключ',
'apikeys.new_mcp': 'Создать ключ MCP-клиента',
'apikeys.new_approval': 'Создать ключ подтверждения',
'apikeys.kind.mcp': 'MCP-клиенты',
'apikeys.kind.approval': 'Подтверждения',
'apikeys.kind.mcp_hint': 'Ключи MCP-клиентов используются для подключения к tools/list и tools/call агента.',
'apikeys.kind.approval_hint': 'Ключи подтверждения используются только внешним интерфейсом, где человек подтверждает действие.',
'apikeys.agent.title': 'Выбор агента',
'apikeys.agent.subtitle': 'Выберите AI-агента для которого выпускается ключ.',
'apikeys.agent.label': 'AI-агент',
@@ -928,7 +968,12 @@ var TRANSLATIONS = {
'apikeys.scope.read': 'Инициализация MCP-сессий, ping сервера и получение списка инструментов для выбранного AI-агента.',
'apikeys.scope.write': 'Выполнение `tools/call` для опубликованных наборов инструментов агента.',
'apikeys.scope.deploy': 'Зарезервировано для deploy-автоматизации. Сейчас также разрешает MCP read/write сценарии.',
'apikeys.scope.approve': 'Подтверждение ожидающего запроса для этого агента.',
'apikeys.scope.deny': 'Отклонение ожидающего запроса для этого агента.',
'apikeys.scope.read_pending': 'Получение списка запросов, ожидающих подтверждения для этого агента.',
'apikeys.modal.title': 'Создать ключ агента',
'apikeys.modal.title_mcp': 'Создать ключ MCP-клиента',
'apikeys.modal.title_approval': 'Создать ключ подтверждения',
'apikeys.modal.name': 'Имя ключа',
'apikeys.modal.name_hint': 'Понятная метка для идентификации ключа. Видна только администраторам.',
'apikeys.modal.name_placeholder': 'например, Production, CI pipeline',
@@ -943,6 +988,7 @@ var TRANSLATIONS = {
'apikeys.status.revoked': 'Отозван',
'apikeys.last_used.never': 'Никогда',
'apikeys.empty.none': 'API-ключей пока нет',
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
'apikeys.empty.search': 'Нет ключей по текущему поиску',
'apikeys.loading': 'Загрузка…',
'apikeys.error.api': 'Воркспейс или API недоступен',
@@ -970,6 +1016,7 @@ var TRANSLATIONS = {
'apikeys.action.revoke': 'Отозвать ключ',
'apikeys.action.delete': 'Удалить',
'apikeys.creating': 'Создание…',
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
// Secrets page
'secrets.title': 'Секреты',
@@ -1372,6 +1419,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': 'Проверка и публикация',
+90 -1
View File
@@ -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,
};
+8
View File
@@ -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();
+18 -1
View File
@@ -22,8 +22,25 @@ test('api keys page opens create key flow', async ({ page }) => {
await expect(page.locator('#btn-create-key')).toBeEnabled();
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create agent key', 'Создать ключ агента'));
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
await page.locator('#modal-done-btn').click();
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
localized('human confirmation interface', 'человек подтверждает действие')
);
await expect(page.locator('#btn-create-key')).toContainText(
localized('Create approval key', 'Создать ключ подтверждения')
);
await page.locator('#btn-create-key').click();
await expect(page.locator('.modal-title')).toHaveText(localized('Create approval key', 'Создать ключ подтверждения'));
await expect(page.locator('#approval-key-warning')).toContainText(
localized('Do not pass this key to an LLM', 'Не передавайте этот ключ LLM')
);
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
});
+35
View File
@@ -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: 'Получить последний курс',
+52
View File
@@ -27,6 +27,41 @@ pub(super) async fn require_machine_access(
Ok(credential)
}
pub(super) async fn require_approval_access(
state: &Arc<AppState>,
path: &AgentRoutePath,
headers: &HeaderMap,
required_scope: PlatformApiKeyScope,
) -> Result<crank_registry::PlatformApiKeyRecord, StatusCode> {
let secret = bearer_token(headers).ok_or(StatusCode::UNAUTHORIZED)?;
let secret_hash = hash_access_secret(secret);
let Some(api_key) = state
.registry
.get_approval_api_key_by_secret_for_agent_slug(
&path.workspace_slug,
&path.agent_slug,
&secret_hash,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
else {
return Err(StatusCode::UNAUTHORIZED);
};
if !approval_allows_scope(&api_key.api_key.scopes, required_scope) {
return Err(StatusCode::FORBIDDEN);
}
let used_at = OffsetDateTime::now_utc();
state
.registry
.touch_platform_api_key(&api_key.api_key.workspace_id, &api_key.api_key.id, &used_at)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(api_key)
}
pub(super) fn bearer_token(headers: &HeaderMap) -> Option<&str> {
let value = headers.get(AUTHORIZATION)?.to_str().ok()?;
let (scheme, token) = value.split_once(' ')?;
@@ -130,9 +165,26 @@ fn allows_scope(scopes: &[PlatformApiKeyScope], required_scope: PlatformApiKeySc
PlatformApiKeyScope::Deploy => scopes
.iter()
.any(|scope| matches!(scope, PlatformApiKeyScope::Deploy)),
PlatformApiKeyScope::Approve
| PlatformApiKeyScope::Deny
| PlatformApiKeyScope::ReadPending => false,
}
}
fn approval_allows_scope(
scopes: &[PlatformApiKeyScope],
required_scope: PlatformApiKeyScope,
) -> bool {
scopes.iter().any(|scope| match required_scope {
PlatformApiKeyScope::Approve => matches!(scope, PlatformApiKeyScope::Approve),
PlatformApiKeyScope::Deny => matches!(scope, PlatformApiKeyScope::Deny),
PlatformApiKeyScope::ReadPending => matches!(scope, PlatformApiKeyScope::ReadPending),
PlatformApiKeyScope::Read | PlatformApiKeyScope::Write | PlatformApiKeyScope::Deploy => {
false
}
})
}
fn security_level_rank(level: OperationSecurityLevel) -> u8 {
match level {
OperationSecurityLevel::Standard => 0,
+272 -6
View File
@@ -10,13 +10,17 @@ use axum::{
extract::{Path, State},
http::{HeaderMap, StatusCode},
response::{IntoResponse, Response, sse::Event},
routing::get,
routing::{get, post},
};
use crank_core::{
AuthProfile, CoordinationStateStore, InvocationLevel, InvocationLog, InvocationLogId,
InvocationSource, InvocationStatus, PlatformApiKeyScope, SecretId,
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthProfile, CoordinationStateStore,
InvocationLevel, InvocationLog, InvocationLogId, InvocationSource, InvocationStatus,
PlatformApiKeyScope, SecretId,
};
use crank_registry::{
CreateApprovalRequest, CreateInvocationLogRequest, DecideApprovalRequest, PostgresRegistry,
PublishedAgentTool,
};
use crank_registry::{CreateInvocationLogRequest, PostgresRegistry, PublishedAgentTool};
use crank_runtime::{
RequestRateLimiter, ResolvedAuth, RuntimeError, RuntimeExecutionRequest, RuntimeExecutor,
RuntimeOperation, RuntimeRequestContext, SecretCrypto,
@@ -29,8 +33,8 @@ use tracing::info;
use crate::{
access::{
credential_allows_security_level, require_machine_access, serialize_machine_access_mode,
serialize_security_level,
credential_allows_security_level, require_approval_access, require_machine_access,
serialize_machine_access_mode, serialize_security_level,
},
auth::{SharedMachineCredentialVerifier, VerifiedMachineCredential},
catalog::PublishedToolCatalog,
@@ -83,6 +87,13 @@ struct ToolCallParams {
arguments: Value,
}
#[derive(Debug, Deserialize)]
struct ApprovalDecisionPayload {
approve: String,
#[serde(default)]
note: Option<String>,
}
#[derive(Clone)]
struct ResolvedToolCall {
tool: PublishedAgentTool,
@@ -100,6 +111,13 @@ pub(super) struct AgentRoutePath {
pub(super) agent_slug: String,
}
#[derive(Clone, Debug, Deserialize)]
struct ApprovalRoutePath {
workspace_slug: String,
agent_slug: String,
approval_id: String,
}
#[allow(clippy::too_many_arguments)]
pub fn build_app(
registry: PostgresRegistry,
@@ -129,6 +147,18 @@ pub fn build_app(
"/v1/{workspace_slug}/{agent_slug}",
get(mcp_get).post(mcp_post).delete(mcp_delete),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals",
get(list_pending_approvals),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/approve",
post(approve_request),
)
.route(
"/v1/{workspace_slug}/{agent_slug}/approvals/{approval_id}/deny",
post(deny_request),
)
.with_state(state)
}
@@ -139,6 +169,122 @@ async fn health() -> Json<Value> {
}))
}
async fn list_pending_approvals(
Path(path): Path<AgentRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
) -> Response {
let key =
match require_approval_access(&state, &path, &headers, PlatformApiKeyScope::ReadPending)
.await
{
Ok(key) => key,
Err(status) => return status.into_response(),
};
let Some(agent_id) = key.api_key.agent_id.as_ref() else {
return StatusCode::FORBIDDEN.into_response();
};
match state
.registry
.list_pending_approval_requests_for_agent(&key.api_key.workspace_id, agent_id)
.await
{
Ok(items) => Json(json!({ "items": items })).into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn approve_request(
Path(path): Path<ApprovalRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Response {
decide_approval_request(
path,
state,
headers,
payload,
PlatformApiKeyScope::Approve,
ApprovalRequestStatus::Approved,
)
.await
}
async fn deny_request(
Path(path): Path<ApprovalRoutePath>,
State(state): State<Arc<AppState>>,
headers: HeaderMap,
Json(payload): Json<ApprovalDecisionPayload>,
) -> Response {
decide_approval_request(
path,
state,
headers,
payload,
PlatformApiKeyScope::Deny,
ApprovalRequestStatus::Denied,
)
.await
}
async fn decide_approval_request(
path: ApprovalRoutePath,
state: Arc<AppState>,
headers: HeaderMap,
payload: ApprovalDecisionPayload,
required_scope: PlatformApiKeyScope,
status: ApprovalRequestStatus,
) -> Response {
let agent_path = AgentRoutePath {
workspace_slug: path.workspace_slug,
agent_slug: path.agent_slug,
};
let key = match require_approval_access(&state, &agent_path, &headers, required_scope).await {
Ok(key) => key,
Err(status) => return status.into_response(),
};
if (status == ApprovalRequestStatus::Approved && !payload.approve.eq_ignore_ascii_case("yes"))
|| (status == ApprovalRequestStatus::Denied && !payload.approve.eq_ignore_ascii_case("no"))
{
return (
StatusCode::BAD_REQUEST,
Json(json!({
"error": "invalid_decision_payload",
"message": "approve must be yes for approve endpoint and no for deny endpoint"
})),
)
.into_response();
}
let Some(agent_id) = key.api_key.agent_id.as_ref() else {
return StatusCode::FORBIDDEN.into_response();
};
let approval_id = ApprovalRequestId::new(path.approval_id);
match state
.registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &key.api_key.workspace_id,
agent_id,
approval_id: &approval_id,
status,
decided_at: OffsetDateTime::now_utc(),
decided_by_key_id: &key.api_key.id,
response_payload: Some(json!({ "approve": payload.approve })),
decision_note: payload.note.as_deref(),
})
.await
{
Ok(Some(record)) => Json(json!(record)).into_response(),
Ok(None) => StatusCode::CONFLICT.into_response(),
Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
}
}
async fn mcp_get(
Path(path): Path<AgentRoutePath>,
State(state): State<Arc<AppState>>,
@@ -591,6 +737,20 @@ async fn handle_base_tool_call(
let tool = execution.tool;
let arguments = execution.arguments;
let operation = runtime_operation(&tool);
if let Some(response) = maybe_create_pending_approval(
&state,
session,
message,
response_mode,
&tool,
&arguments,
transport_request_id,
)
.await
{
return response;
}
let mut runtime_request_context = RuntimeRequestContext::from_request_id(transport_request_id)
.with_response_cache_scope(
tool.workspace_id.as_str().to_owned(),
@@ -674,6 +834,112 @@ async fn handle_base_tool_call(
}
}
async fn maybe_create_pending_approval(
state: &Arc<AppState>,
session: &SessionState,
message: &Value,
response_mode: ResponseMode,
tool: &PublishedAgentTool,
arguments: &Value,
transport_request_id: &str,
) -> Option<Response> {
let policy = tool.operation.execution_config.approval_policy.as_ref()?;
if !policy.required {
return None;
}
let approval_id = ApprovalRequestId::new(format!("approval_{}", uuid::Uuid::now_v7().simple()));
let now = OffsetDateTime::now_utc();
let expires_at = now + time::Duration::seconds(i64::from(policy.ttl_seconds));
let approval_url = approval_url_for(tool, &approval_id);
let response_payload = json!({
"status": "approval_required",
"approval_id": approval_id.as_str(),
"approval_url": approval_url,
"approve": {
"method": "POST",
"url": format!("{approval_url}/approve"),
"body": { "approve": "yes" }
},
"deny": {
"method": "POST",
"url": format!("{approval_url}/deny"),
"body": { "approve": "no" }
},
"expires_at": expires_at,
"risk_level": policy.risk_level,
"confirmation_title": policy.confirmation_title,
"confirmation_body": policy.confirmation_body_template,
"payload_preview": if policy.show_payload_preview {
arguments.clone()
} else {
Value::Null
},
});
let approval = ApprovalRequest {
id: approval_id,
workspace_id: tool.workspace_id.clone(),
agent_id: tool.agent_id.clone(),
operation_id: tool.operation.id.clone(),
operation_version: tool.operation.version,
status: ApprovalRequestStatus::Pending,
risk_level: policy.risk_level,
confirmation_title: policy.confirmation_title.clone(),
confirmation_body: policy.confirmation_body_template.clone(),
request_payload: arguments.clone(),
response_payload: None,
created_at: now,
expires_at,
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
if let Err(error) = state
.registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
{
return Some(internal_jsonrpc_error(message, error));
}
let _ = persist_invocation(
state,
tool,
InvocationRecord {
request_id: Some(transport_request_id),
tool_name: &tool.tool_name,
status: InvocationStatus::Ok,
level: InvocationLevel::Info,
message: "agent tool call is waiting for human approval",
status_code: None,
error_kind: None,
duration: Duration::from_millis(0),
request_preview: arguments.clone(),
response_preview: response_payload.clone(),
},
)
.await;
Some(success_tool_response(
message,
response_mode,
&session.protocol_version,
response_payload,
))
}
fn approval_url_for(tool: &PublishedAgentTool, approval_id: &ApprovalRequestId) -> String {
format!(
"/v1/{}/{}/approvals/{}",
tool.workspace_slug,
tool.agent_slug,
approval_id.as_str()
)
}
async fn handle_initialize(
state: Arc<AppState>,
path: &AgentRoutePath,
@@ -106,6 +106,7 @@ fn operation() -> RegistryOperation {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+28 -1
View File
@@ -35,12 +35,22 @@ pub enum PlatformApiKeyStatus {
Revoked,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyKind {
McpClient,
Approval,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum PlatformApiKeyScope {
Read,
Write,
Deploy,
Approve,
Deny,
ReadPending,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
@@ -82,6 +92,8 @@ pub struct PlatformApiKey {
pub workspace_id: WorkspaceId,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub agent_id: Option<AgentId>,
#[serde(default = "default_platform_api_key_kind")]
pub key_kind: PlatformApiKeyKind,
pub name: String,
pub prefix: String,
pub scopes: Vec<PlatformApiKeyScope>,
@@ -90,6 +102,14 @@ pub struct PlatformApiKey {
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub last_used_at: Option<OffsetDateTime>,
#[serde(with = "time::serde::rfc3339::option")]
pub expires_at: Option<OffsetDateTime>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub allowed_origins: Vec<String>,
}
fn default_platform_api_key_kind() -> PlatformApiKeyKind {
PlatformApiKeyKind::McpClient
}
#[cfg(test)]
@@ -97,7 +117,10 @@ mod tests {
use serde_json::json;
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{PlatformApiKey, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus};
use super::{
PlatformApiKey, PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User,
UserStatus,
};
use crate::ids::{AgentId, PlatformApiKeyId, UserId, WorkspaceId};
#[test]
@@ -138,16 +161,20 @@ mod tests {
id: PlatformApiKeyId::new("pk_01"),
workspace_id: WorkspaceId::new("ws_01"),
agent_id: Some(AgentId::new("agent_01")),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: OffsetDateTime::parse("2026-03-25T12:01:00Z", &Rfc3339).unwrap(),
last_used_at: Some(OffsetDateTime::parse("2026-03-25T12:05:00Z", &Rfc3339).unwrap()),
expires_at: None,
allowed_origins: Vec::new(),
};
let value = serde_json::to_value(&api_key).unwrap();
assert_eq!(value["key_kind"], json!("mcp_client"));
assert_eq!(value["created_at"], json!("2026-03-25T12:01:00Z"));
assert_eq!(value["last_used_at"], json!("2026-03-25T12:05:00Z"));
}
+39
View File
@@ -0,0 +1,39 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use time::OffsetDateTime;
use crate::ids::{AgentId, ApprovalRequestId, OperationId, PlatformApiKeyId, WorkspaceId};
use crate::operation::OperationApprovalRiskLevel;
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalRequestStatus {
#[default]
Pending,
Approved,
Denied,
Expired,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ApprovalRequest {
pub id: ApprovalRequestId,
pub workspace_id: WorkspaceId,
pub agent_id: AgentId,
pub operation_id: OperationId,
pub operation_version: u32,
pub status: ApprovalRequestStatus,
pub risk_level: OperationApprovalRiskLevel,
pub confirmation_title: String,
pub confirmation_body: String,
pub request_payload: Value,
pub response_payload: Option<Value>,
#[serde(with = "time::serde::rfc3339")]
pub created_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339")]
pub expires_at: OffsetDateTime,
#[serde(with = "time::serde::rfc3339::option")]
pub decided_at: Option<OffsetDateTime>,
pub decided_by_key_id: Option<PlatformApiKeyId>,
pub decision_note: Option<String>,
}
+1
View File
@@ -53,6 +53,7 @@ define_id!(UserSessionId);
define_id!(AgentId);
define_id!(InvitationId);
define_id!(PlatformApiKeyId);
define_id!(ApprovalRequestId);
define_id!(InvocationLogId);
define_id!(AuditEventId);
define_id!(SecretId);
+19 -13
View File
@@ -1,5 +1,6 @@
pub mod access;
pub mod agent;
pub mod approval;
pub mod auth;
pub mod cache;
pub mod edition;
@@ -15,9 +16,10 @@ pub mod workspace;
pub mod domain {
pub use crate::access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use crate::agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use crate::approval::{ApprovalRequest, ApprovalRequestStatus};
pub use crate::auth::{
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
BearerAuthConfig,
@@ -31,9 +33,9 @@ pub mod domain {
ProductEdition,
};
pub use crate::ids::{
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId,
OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId,
WorkspaceId,
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
};
pub use crate::observability::{
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod,
@@ -41,9 +43,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};
@@ -85,9 +88,10 @@ pub mod ports {
pub use access::{
InvitationStatus, InvitationToken, Membership, MembershipRole, PlatformApiKey,
PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
PlatformApiKeyKind, PlatformApiKeyScope, PlatformApiKeyStatus, User, UserStatus,
};
pub use agent::{Agent, AgentOperationBinding, AgentStatus, AgentVersion};
pub use approval::{ApprovalRequest, ApprovalRequestStatus};
pub use auth::{
ApiKeyHeaderAuthConfig, ApiKeyQueryAuthConfig, AuthConfig, AuthProfile, BasicAuthConfig,
BearerAuthConfig,
@@ -120,17 +124,19 @@ pub use ext::protocol::{
SharedProtocolAdapter,
};
pub use ids::{
AgentId, AuditEventId, AuthProfileId, DescriptorId, InvitationId, InvocationLogId, OperationId,
PlatformApiKeyId, SampleId, SecretId, ToolId, UserId, UserSessionId, WorkspaceId,
AgentId, ApprovalRequestId, AuditEventId, AuthProfileId, DescriptorId, InvitationId,
InvocationLogId, OperationId, PlatformApiKeyId, SampleId, SecretId, ToolId, UserId,
UserSessionId, WorkspaceId,
};
pub use observability::{
InvocationLevel, InvocationLog, InvocationSource, InvocationStatus, UsagePeriod, UsageRollup,
};
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};
+32
View File
@@ -103,6 +103,35 @@ pub struct OperationSafetyPolicy {
pub confirmation: Option<ConfirmationPolicy>,
}
#[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<OperationSafetyPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub approval_policy: Option<OperationApprovalPolicy>,
#[serde(skip_serializing_if = "Option::is_none")]
pub auth_profile_ref: Option<AuthProfileId>,
#[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
pub headers: BTreeMap<String, String>,
@@ -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(),
},
+1
View File
@@ -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(),
}
+24 -22
View File
@@ -9,12 +9,12 @@ pub use ext::{ExtensionMigration, RegistryExtension, apply_extension_migrations}
pub mod records {
pub use crate::model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, DescriptorKind, DescriptorMetadata,
ImportJob, ImportJobId, ImportJobKind, ImportJobStatus, InvitationRecord,
InvocationLogRecord, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page,
PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind, SecretRecord,
SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord, DescriptorKind,
DescriptorMetadata, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
InvitationRecord, InvocationLogRecord, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
Page, PlatformApiKeyRecord, PublishedAgentTool, RegistryOperation, SampleKind,
SecretRecord, SecretVersionRecord, SessionRecord, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageRollupRecord, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord, WorkspaceUpstream, WorkspaceUpstreamId,
YamlImportJob, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
@@ -23,13 +23,14 @@ pub mod records {
pub mod requests {
pub use crate::model::{
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateImportJobRequest,
CreateInvitationRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, FinishImportJobRequest, ListInvocationLogsQuery,
PublishAgentRequest, PublishRequest, RotateSecretRequest, SaveAgentBindingsRequest,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
SaveWorkspaceUpstreamRequest, UpdateWorkspaceRequest, UsageQuery,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
FinishImportJobRequest, ListInvocationLogsQuery, PublishAgentRequest, PublishRequest,
RotateSecretRequest, SaveAgentBindingsRequest, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest,
UpdateWorkspaceRequest, UsageQuery,
};
}
@@ -39,15 +40,16 @@ pub mod infrastructure {
}
pub use model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind,
ImportJobStatus, InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery,
MembershipRecord, OperationAgentRef, OperationSampleMetadata, OperationSummary,
OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest,
PublishRequest, PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
FinishImportJobRequest, ImportJob, ImportJobId, ImportJobKind, ImportJobStatus,
InvitationRecord, InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord,
OperationAgentRef, OperationSampleMetadata, OperationSummary, OperationUsageSummary,
OperationVersionRecord, Page, PlatformApiKeyRecord, PublishAgentRequest, PublishRequest,
PublishedAgentTool, RegistryOperation, RotateSecretRequest, SampleKind,
SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, SaveWorkspaceUpstreamRequest, SecretRecord, SecretVersionRecord,
SessionRecord, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
+46 -1
View File
@@ -148,11 +148,14 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
name text not null,
prefix text not null,
secret_hash text not null,
key_kind text not null default 'mcp_client',
scopes_json jsonb not null,
status text not null,
created_at timestamptz not null,
last_used_at timestamptz null,
revoked_at timestamptz null
revoked_at timestamptz null,
expires_at timestamptz null,
allowed_origins_json jsonb not null default '[]'::jsonb
)",
)
.execute(pool)
@@ -166,6 +169,19 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
query("alter table platform_api_keys add column if not exists agent_id text null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists key_kind text not null default 'mcp_client'",
)
.execute(pool)
.await?;
query("alter table platform_api_keys add column if not exists expires_at timestamptz null")
.execute(pool)
.await?;
query(
"alter table platform_api_keys add column if not exists allowed_origins_json jsonb not null default '[]'::jsonb",
)
.execute(pool)
.await?;
query(
"insert into workspaces (
@@ -543,6 +559,35 @@ pub async fn apply_postgres(pool: &PgPool) -> Result<(), sqlx::Error> {
.execute(pool)
.await?;
query(
"create table if not exists approval_requests (
id text primary key,
workspace_id text not null references workspaces(id) on delete cascade,
agent_id text not null references agents(id) on delete cascade,
operation_id text not null references operations(id) on delete cascade,
operation_version integer not null,
status text not null,
risk_level text not null,
confirmation_title text not null,
confirmation_body text not null,
request_payload_json jsonb not null,
response_payload_json jsonb null,
created_at timestamptz not null,
expires_at timestamptz not null,
decided_at timestamptz null,
decided_by_key_id text null references platform_api_keys(id) on delete set null,
decision_note text null
)",
)
.execute(pool)
.await?;
query(
"create index if not exists approval_requests_agent_status_idx
on approval_requests(workspace_id, agent_id, status, expires_at)",
)
.execute(pool)
.await?;
query(
"create table if not exists invocation_logs (
id text primary key,
+28 -5
View File
@@ -1,9 +1,10 @@
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, DescriptorId,
ExportMode, InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole,
Operation, OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, Protocol,
SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User, UserSessionId,
Workspace, WorkspaceId,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest,
ApprovalRequestId, ApprovalRequestStatus, AuthProfile, DescriptorId, ExportMode,
InvitationToken, InvocationLevel, InvocationLog, InvocationSource, MembershipRole, Operation,
OperationId, OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId,
Protocol, SampleId, Secret, SecretId, SecretVersion, UsagePeriod, UsageRollup, User,
UserSessionId, Workspace, WorkspaceId,
};
use crank_mapping::MappingSet;
use crank_schema::Schema;
@@ -95,6 +96,11 @@ pub struct PlatformApiKeyRecord {
pub api_key: PlatformApiKey,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ApprovalRequestRecord {
pub approval: ApprovalRequest,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SecretRecord {
pub secret: Secret,
@@ -513,6 +519,23 @@ pub struct CreatePlatformApiKeyRequest<'a> {
pub secret_hash: &'a str,
}
#[derive(Clone, Debug)]
pub struct CreateApprovalRequest<'a> {
pub approval: &'a ApprovalRequest,
}
#[derive(Clone, Debug)]
pub struct DecideApprovalRequest<'a> {
pub workspace_id: &'a WorkspaceId,
pub agent_id: &'a AgentId,
pub approval_id: &'a ApprovalRequestId,
pub status: ApprovalRequestStatus,
pub decided_at: OffsetDateTime,
pub decided_by_key_id: &'a PlatformApiKeyId,
pub response_payload: Option<Value>,
pub decision_note: Option<&'a str>,
}
#[derive(Clone, Debug, PartialEq)]
pub struct CreateSecretRequest<'a> {
pub secret: &'a Secret,
+103 -5
View File
@@ -11,12 +11,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
and agent_id = $2
@@ -34,12 +37,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -55,12 +66,15 @@ impl PostgresRegistry {
id,
workspace_id,
agent_id,
key_kind,
name,
prefix,
scopes_json,
status,
created_at,
last_used_at
last_used_at,
expires_at,
allowed_origins_json
from platform_api_keys
where workspace_id = $1
order by created_at desc",
@@ -76,12 +90,20 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(
&row.get::<String, _>("key_kind"),
"key_kind",
)?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -99,19 +121,24 @@ impl PostgresRegistry {
k.id,
k.workspace_id,
k.agent_id,
k.key_kind,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at
k.last_used_at,
k.expires_at,
k.allowed_origins_json
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.key_kind = 'mcp_client'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
limit 1",
)
.bind(workspace_slug)
@@ -126,12 +153,77 @@ impl PostgresRegistry {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
.transpose()
}
pub async fn get_approval_api_key_by_secret_for_agent_slug(
&self,
workspace_slug: &str,
agent_slug: &str,
secret_hash: &str,
) -> Result<Option<PlatformApiKeyRecord>, RegistryError> {
let row = sqlx::query(
"select
k.id,
k.workspace_id,
k.agent_id,
k.key_kind,
k.name,
k.prefix,
k.scopes_json,
k.status,
k.created_at,
k.last_used_at,
k.expires_at,
k.allowed_origins_json
from platform_api_keys k
join workspaces w on w.id = k.workspace_id
join agents a on a.id = k.agent_id
where w.slug = $1
and a.slug = $2
and k.secret_hash = $3
and k.key_kind = 'approval'
and k.status = 'active'
and (k.expires_at is null or k.expires_at > now())
limit 1",
)
.bind(workspace_slug)
.bind(agent_slug)
.bind(secret_hash)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
Ok(PlatformApiKeyRecord {
api_key: PlatformApiKey {
id: PlatformApiKeyId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: row.get::<Option<String>, _>("agent_id").map(AgentId::new),
key_kind: deserialize_enum_text(&row.get::<String, _>("key_kind"), "key_kind")?,
name: row.get("name"),
prefix: row.get("prefix"),
scopes: deserialize_json_value(row.get::<Value, _>("scopes_json"))?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "status")?,
created_at: row.get("created_at"),
last_used_at: row.get("last_used_at"),
expires_at: row.get("expires_at"),
allowed_origins: deserialize_json_value(
row.get::<Value, _>("allowed_origins_json"),
)?,
},
})
})
@@ -150,13 +242,16 @@ impl PostgresRegistry {
name,
prefix,
secret_hash,
key_kind,
scopes_json,
status,
created_at,
last_used_at,
revoked_at
revoked_at,
expires_at,
allowed_origins_json
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9::timestamptz, $10::timestamptz, $11::timestamptz
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10::timestamptz, $11::timestamptz, $12::timestamptz, $13::timestamptz, $14
)",
)
.bind(request.api_key.id.as_str())
@@ -165,11 +260,14 @@ impl PostgresRegistry {
.bind(&request.api_key.name)
.bind(&request.api_key.prefix)
.bind(request.secret_hash)
.bind(serialize_enum_text(&request.api_key.key_kind, "key_kind")?)
.bind(Json(serialize_json_value(&request.api_key.scopes)?))
.bind(serialize_enum_text(&request.api_key.status, "status")?)
.bind(request.api_key.created_at)
.bind(request.api_key.last_used_at)
.bind(Option::<&str>::None)
.bind(request.api_key.expires_at)
.bind(Json(serialize_json_value(&request.api_key.allowed_origins)?))
.execute(&self.pool)
.await;
@@ -0,0 +1,218 @@
use super::*;
impl PostgresRegistry {
pub async fn create_approval_request(
&self,
request: CreateApprovalRequest<'_>,
) -> Result<(), RegistryError> {
sqlx::query(
"insert into approval_requests (
id,
workspace_id,
agent_id,
operation_id,
operation_version,
status,
risk_level,
confirmation_title,
confirmation_body,
request_payload_json,
response_payload_json,
created_at,
expires_at,
decided_at,
decided_by_key_id,
decision_note
) values (
$1, $2, $3, $4, $5, $6, $7, $8, $9, $10,
$11, $12::timestamptz, $13::timestamptz, $14::timestamptz,
$15, $16
)",
)
.bind(request.approval.id.as_str())
.bind(request.approval.workspace_id.as_str())
.bind(request.approval.agent_id.as_str())
.bind(request.approval.operation_id.as_str())
.bind(to_db_version(request.approval.operation_version))
.bind(serialize_enum_text(
&request.approval.status,
"approval_status",
)?)
.bind(serialize_enum_text(
&request.approval.risk_level,
"approval_risk_level",
)?)
.bind(&request.approval.confirmation_title)
.bind(&request.approval.confirmation_body)
.bind(Json(&request.approval.request_payload))
.bind(request.approval.response_payload.as_ref().map(Json))
.bind(request.approval.created_at)
.bind(request.approval.expires_at)
.bind(request.approval.decided_at)
.bind(
request
.approval
.decided_by_key_id
.as_ref()
.map(PlatformApiKeyId::as_str),
)
.bind(request.approval.decision_note.as_deref())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn list_pending_approval_requests_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
) -> Result<Vec<ApprovalRequestRecord>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
agent_id,
operation_id,
operation_version,
status,
risk_level,
confirmation_title,
confirmation_body,
request_payload_json,
response_payload_json,
created_at,
expires_at,
decided_at,
decided_by_key_id,
decision_note
from approval_requests
where workspace_id = $1
and agent_id = $2
and status = 'pending'
and expires_at > now()
order by created_at asc",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.fetch_all(&self.pool)
.await?;
rows.into_iter().map(map_approval_request_row).collect()
}
pub async fn get_approval_request_for_agent(
&self,
workspace_id: &WorkspaceId,
agent_id: &AgentId,
approval_id: &ApprovalRequestId,
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
let row = sqlx::query(
"select
id,
workspace_id,
agent_id,
operation_id,
operation_version,
status,
risk_level,
confirmation_title,
confirmation_body,
request_payload_json,
response_payload_json,
created_at,
expires_at,
decided_at,
decided_by_key_id,
decision_note
from approval_requests
where workspace_id = $1
and agent_id = $2
and id = $3
limit 1",
)
.bind(workspace_id.as_str())
.bind(agent_id.as_str())
.bind(approval_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(map_approval_request_row).transpose()
}
pub async fn decide_approval_request(
&self,
request: DecideApprovalRequest<'_>,
) -> Result<Option<ApprovalRequestRecord>, RegistryError> {
let row = sqlx::query(
"update approval_requests
set status = $1,
response_payload_json = $2,
decided_at = $3::timestamptz,
decided_by_key_id = $4,
decision_note = $5
where workspace_id = $6
and agent_id = $7
and id = $8
and status = 'pending'
and expires_at > $3::timestamptz
returning
id,
workspace_id,
agent_id,
operation_id,
operation_version,
status,
risk_level,
confirmation_title,
confirmation_body,
request_payload_json,
response_payload_json,
created_at,
expires_at,
decided_at,
decided_by_key_id,
decision_note",
)
.bind(serialize_enum_text(&request.status, "approval_status")?)
.bind(request.response_payload.as_ref().map(Json))
.bind(request.decided_at)
.bind(request.decided_by_key_id.as_str())
.bind(request.decision_note)
.bind(request.workspace_id.as_str())
.bind(request.agent_id.as_str())
.bind(request.approval_id.as_str())
.fetch_optional(&self.pool)
.await?;
row.map(map_approval_request_row).transpose()
}
}
fn map_approval_request_row(row: PgRow) -> Result<ApprovalRequestRecord, RegistryError> {
Ok(ApprovalRequestRecord {
approval: ApprovalRequest {
id: ApprovalRequestId::new(row.get::<String, _>("id")),
workspace_id: WorkspaceId::new(row.get::<String, _>("workspace_id")),
agent_id: AgentId::new(row.get::<String, _>("agent_id")),
operation_id: OperationId::new(row.get::<String, _>("operation_id")),
operation_version: from_db_version(row.get("operation_version"), "operation_version")?,
status: deserialize_enum_text(&row.get::<String, _>("status"), "approval_status")?,
risk_level: deserialize_enum_text(
&row.get::<String, _>("risk_level"),
"approval_risk_level",
)?,
confirmation_title: row.get("confirmation_title"),
confirmation_body: row.get("confirmation_body"),
request_payload: row.get::<Value, _>("request_payload_json"),
response_payload: row.get::<Option<Value>, _>("response_payload_json"),
created_at: row.get("created_at"),
expires_at: row.get("expires_at"),
decided_at: row.get("decided_at"),
decided_by_key_id: row
.get::<Option<String>, _>("decided_by_key_id")
.map(PlatformApiKeyId::new),
decision_note: row.get("decision_note"),
},
})
}
+11 -8
View File
@@ -1,5 +1,6 @@
mod agent;
mod api_key;
mod approval;
mod auth;
mod connection;
mod import_job;
@@ -13,10 +14,11 @@ mod workspace;
mod yaml_import;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, HttpMethod,
InvitationId, InvitationToken, InvocationLog, InvocationLogId, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Secret, SecretId,
SecretVersion, Target, UsageRollup, User, UserId, UserSessionId, Workspace, WorkspaceId,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApprovalRequest, ApprovalRequestId,
AuthProfile, HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId,
MembershipRole, OperationId, OperationStatus, PlatformApiKey, PlatformApiKeyId,
PlatformApiKeyStatus, Secret, SecretId, SecretVersion, Target, UsageRollup, User, UserId,
UserSessionId, Workspace, WorkspaceId,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
@@ -28,10 +30,11 @@ pub use pool_config::{PostgresPoolConfig, PostgresPoolConfigError};
use crate::{
error::RegistryError,
model::{
AgentSummary, AgentVersionRecord, AuthUserRecord, CreateAgentDraftVersionRequest,
CreateAgentRequest, CreateImportJobRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest,
AgentSummary, AgentVersionRecord, ApprovalRequestRecord, AuthUserRecord,
CreateAgentDraftVersionRequest, CreateAgentRequest, CreateApprovalRequest,
CreateImportJobRequest, CreateInvitationRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateSecretRequest, CreateVersionRequest,
CreateWorkspaceRequest, CreateYamlImportJobRequest, DecideApprovalRequest,
DescriptorMetadata, FinishImportJobRequest, ImportJob, ImportJobId, InvitationRecord,
InvocationLogRecord, ListInvocationLogsQuery, MembershipRecord, OperationAgentRef,
OperationSampleMetadata, OperationSummary, OperationUsageSummary, OperationVersionRecord,
@@ -5,10 +5,11 @@ use super::common::*;
use std::collections::BTreeMap;
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig, AuthConfig,
AuthKind, AuthProfile, ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft,
GeneratedDraftStatus, HttpMethod, InvocationLog, MembershipRole, OperationId,
OperationSecurityLevel, OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, ApiKeyHeaderAuthConfig,
ApprovalRequest, ApprovalRequestId, ApprovalRequestStatus, AuthConfig, AuthKind, AuthProfile,
ConfigExport, ExecutionConfig, ExportMode, GeneratedDraft, GeneratedDraftStatus, HttpMethod,
InvocationLog, MembershipRole, OperationApprovalRiskLevel, OperationId, OperationSecurityLevel,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyKind, PlatformApiKeyScope,
PlatformApiKeyStatus, Protocol, RestTarget, RetryPolicy, Samples, SecretId, Target,
ToolDescription, ToolExample, User, UserId, UserSessionId, WizardState, Workspace, WorkspaceId,
};
@@ -19,13 +20,13 @@ use sqlx::{Executor, PgPool, postgres::PgPoolOptions};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use crank_registry::{
CreateAgentRequest, CreateInvocationLogRequest, CreatePlatformApiKeyRequest,
CreateVersionRequest, CreateWorkspaceRequest, CreateYamlImportJobRequest, DescriptorKind,
DescriptorMetadata, OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry,
PublishAgentRequest, PublishRequest, RegistryError, RegistryOperation, SampleKind,
SaveAuthProfileRequest, SaveDescriptorMetadataRequest, SaveSampleMetadataRequest,
UpdateWorkspaceRequest, WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId,
YamlImportJobStatus,
CreateAgentRequest, CreateApprovalRequest, CreateInvocationLogRequest,
CreatePlatformApiKeyRequest, CreateVersionRequest, CreateWorkspaceRequest,
CreateYamlImportJobRequest, DecideApprovalRequest, DescriptorKind, DescriptorMetadata,
OperationSampleMetadata, PlatformApiKeyRecord, PostgresRegistry, PublishAgentRequest,
PublishRequest, RegistryError, RegistryOperation, SampleKind, SaveAuthProfileRequest,
SaveDescriptorMetadataRequest, SaveSampleMetadataRequest, UpdateWorkspaceRequest,
WorkspaceRecord, YamlImportJobCompletion, YamlImportJobId, YamlImportJobStatus,
};
fn test_workspace_id() -> WorkspaceId {
@@ -372,12 +373,15 @@ async fn manages_platform_api_key_read_paths() {
id: PlatformApiKeyId::new("key_01"),
workspace_id: workspace.id.clone(),
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::McpClient,
name: "Primary".to_owned(),
prefix: "crk_live".to_owned(),
scopes: vec![PlatformApiKeyScope::Read, PlatformApiKeyScope::Write],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:01:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
let secret_hash = "secret_hash_01";
@@ -451,3 +455,126 @@ async fn manages_platform_api_key_read_paths() {
database.cleanup().await;
}
#[tokio::test]
async fn manages_approval_request_lifecycle() {
let database = TestDatabase::new().await;
let registry = database.registry().await;
let workspace_id = test_workspace_id();
let operation = test_operation("op_approval_01", 1, OperationStatus::Draft);
let agent = test_agent("agent_approval_01", AgentStatus::Draft);
let version = test_agent_version(&agent.id, 1, AgentStatus::Draft);
let approval_key = PlatformApiKey {
id: PlatformApiKeyId::new("key_approval_01"),
workspace_id: workspace_id.clone(),
agent_id: Some(agent.id.clone()),
key_kind: PlatformApiKeyKind::Approval,
name: "Approval key".to_owned(),
prefix: "crk_appr".to_owned(),
scopes: vec![PlatformApiKeyScope::Approve, PlatformApiKeyScope::Deny],
status: PlatformApiKeyStatus::Active,
created_at: timestamp("2026-03-25T12:00:00Z"),
last_used_at: None,
expires_at: None,
allowed_origins: Vec::new(),
};
let approval = ApprovalRequest {
id: ApprovalRequestId::new("approval_01"),
workspace_id: workspace_id.clone(),
agent_id: agent.id.clone(),
operation_id: operation.id.clone(),
operation_version: 1,
status: ApprovalRequestStatus::Pending,
risk_level: OperationApprovalRiskLevel::Dangerous,
confirmation_title: "Confirm action".to_owned(),
confirmation_body: "Check payload before running.".to_owned(),
request_payload: json!({"amount": 100}),
response_payload: None,
created_at: timestamp("2026-03-25T12:01:00Z"),
expires_at: timestamp("2027-03-25T12:06:00Z"),
decided_at: None,
decided_by_key_id: None,
decision_note: None,
};
registry
.create_operation(&workspace_id, &operation, None)
.await
.unwrap();
registry
.create_agent(CreateAgentRequest {
agent: &agent,
version: &version,
bindings: &[],
})
.await
.unwrap();
registry
.create_platform_api_key(CreatePlatformApiKeyRequest {
api_key: &approval_key,
secret_hash: "approval-secret-hash",
})
.await
.unwrap();
registry
.create_approval_request(CreateApprovalRequest {
approval: &approval,
})
.await
.unwrap();
let pending = registry
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
.await
.unwrap();
assert_eq!(pending.len(), 1);
assert_eq!(pending[0].approval, approval);
let decided = registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
status: ApprovalRequestStatus::Approved,
decided_at: timestamp("2026-03-25T12:02:00Z"),
decided_by_key_id: &approval_key.id,
response_payload: Some(json!({"approve": "yes"})),
decision_note: Some("confirmed in test"),
})
.await
.unwrap()
.unwrap();
assert_eq!(decided.approval.status, ApprovalRequestStatus::Approved);
assert_eq!(
decided.approval.decided_by_key_id,
Some(approval_key.id.clone())
);
assert_eq!(
decided.approval.response_payload,
Some(json!({"approve": "yes"}))
);
let pending_after_decision = registry
.list_pending_approval_requests_for_agent(&workspace_id, &agent.id)
.await
.unwrap();
assert!(pending_after_decision.is_empty());
let repeated_decision = registry
.decide_approval_request(DecideApprovalRequest {
workspace_id: &workspace_id,
agent_id: &agent.id,
approval_id: &approval.id,
status: ApprovalRequestStatus::Denied,
decided_at: timestamp("2026-03-25T12:03:00Z"),
decided_by_key_id: &approval_key.id,
response_payload: Some(json!({"approve": "no"})),
decision_note: None,
})
.await
.unwrap();
assert!(repeated_decision.is_none());
database.cleanup().await;
}
@@ -160,6 +160,7 @@ fn destructive_delete_operation() -> Operation<Schema, MappingSet> {
class: OperationSafetyClass::Destructive,
confirmation: Some(ConfirmationPolicy { ttl_ms: 60_000 }),
}),
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -146,6 +146,7 @@ fn idempotent_post_operation() -> Operation<Schema, MappingSet> {
header_name: Some("Idempotency-Key".to_owned()),
}),
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
@@ -71,6 +71,7 @@ fn no_input_get_operation() -> Operation<Schema, MappingSet> {
response_cache: None,
idempotency: None,
safety: None,
approval_policy: None,
auth_profile_ref: None,
headers: BTreeMap::new(),
},
+53
View File
@@ -32,6 +32,8 @@ Authorization: Bearer <agent_api_key>
Ключ выдается в разделе **API ключи**. Полное значение показывается только один раз при создании.
Для операций с подтверждением человеком нужен отдельный ключ подтверждения. Его тоже выдают в разделе **API ключи**, но в режиме **Подтверждения**. Такой ключ нельзя передавать LLM или MCP-клиенту. Он нужен только вашему внешнему интерфейсу, где пользователь нажимает «Подтвердить» или «Отклонить».
## Поддерживаемые методы
MCP methods:
@@ -148,6 +150,57 @@ Crank выполнит REST-запрос:
GET https://api.frankfurter.dev/v1/latest?base=USD&symbols=EUR
```
## Операции с подтверждением человеком
Если в мастере операции включено **Подтверждение человеком**, первый `tools/call` не выполняет REST-запрос сразу. Вместо этого Crank создает ожидающий запрос на подтверждение и возвращает MCP-клиенту структурированный результат:
```json
{
"status": "approval_required",
"approval_id": "approval_...",
"approval_url": "/v1/default/sales/approvals/approval_...",
"approve": {
"method": "POST",
"url": "/v1/default/sales/approvals/approval_.../approve",
"body": { "approve": "yes" }
},
"deny": {
"method": "POST",
"url": "/v1/default/sales/approvals/approval_.../deny",
"body": { "approve": "no" }
}
}
```
`approval_url` в ответе является путем на MCP-сервере. Если вы публикуете MCP через префикс `/mcp`, внешний URL будет начинаться с `/mcp/v1/...`.
Внешний интерфейс подтверждения работает отдельным ключом подтверждения:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals \
-H 'Authorization: Bearer <approval_api_key>'
```
Подтверждение:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/approve \
-H 'Authorization: Bearer <approval_api_key>' \
-H 'Content-Type: application/json' \
--data '{ "approve": "yes", "note": "Пользователь подтвердил действие" }'
```
Отклонение:
```bash
curl https://crank.example.com/mcp/v1/default/sales/approvals/<approval_id>/deny \
-H 'Authorization: Bearer <approval_api_key>' \
-H 'Content-Type: application/json' \
--data '{ "approve": "no", "note": "Пользователь отклонил действие" }'
```
Ключ MCP-клиента не подходит для этих endpoints. Ключ подтверждения, наоборот, не подходит для `initialize`, `tools/list` и `tools/call`.
## Как формируется каталог инструментов
MCP-клиент видит только опубликованные операции, которые привязаны к опубликованному агенту.
+5 -1
View File
@@ -60,12 +60,16 @@
## API ключи
API-ключ выдается на конкретного агента. Ключ позволяет MCP-клиенту:
API-ключ выдается на конкретного агента. В Community есть два режима ключей.
Ключ MCP-клиента позволяет:
- открыть MCP-сессию;
- получить список инструментов агента;
- вызвать опубликованный инструмент.
Ключ подтверждения используется только внешним интерфейсом, где человек подтверждает или отклоняет опасное действие. Такой ключ нельзя передавать MCP-клиенту или LLM.
Полное значение ключа показывается только при создании.
## Секреты