blob: 5443de8e0ec563f27a49097f53dd9129f98513d3 [file]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
//! Canonical interaction facts. The store owns append-only outcome commitment.
use maka_sandbox::grant;
use serde::{Deserialize, Serialize};
mod decode;
mod question;
mod validation;
use crate::capability::{FormField, FormRequester, FormResult};
pub use question::{InteractionQuestion, QuestionOption};
pub use validation::{MAX_SAFE_INTEGER, entity_id};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum GrantCapability {
Browser,
ComputerUse,
DesktopMcp,
Mcp,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum GrantScope {
BrowserOrigin {
origin: String,
},
Capability {},
McpTool {
server_id: String,
tool_name: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase")]
pub struct GrantTarget {
pub provider_id: String,
pub contract_id: String,
pub server_id: String,
pub tool_name: String,
pub capability: GrantCapability,
pub scope: GrantScope,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Decision {
Allow,
Deny,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ClosureReason {
TurnStopped,
TurnTerminal,
ProducerCancelled,
TimedOut,
HostRestarted,
ProviderDisconnected,
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum InteractionRequest {
/// Consent for one already journaled service call; never a service grant.
ServiceApproval {
tool_use_id: String,
tool_name: String,
service: String,
method: String,
binding: String,
through: u64,
},
Permissions {
#[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable<String>")]
tool_use_id: Option<String>,
base_revision: u64,
request: PermissionRequest,
},
Question {
tool_use_id: String,
questions: Vec<InteractionQuestion>,
},
Form {
tool_use_id: String,
message: String,
requester: FormRequester,
fields: Vec<FormField>,
},
ClientCapability {
tool_use_id: String,
target: GrantTarget,
/// Agent-owned approvals expire with this original permission boundary.
/// Older capability records without a boundary remain readable.
#[serde(skip_serializing_if = "Option::is_none")]
base_revision: Option<u64>,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
pub enum InteractionAnswer {
ServiceApproval {
decision: Decision,
},
Permissions {
decision: grant::Decision,
},
Question {
answers: Vec<Option<String>>,
},
Form {
#[serde(flatten)]
result: FormResult,
},
ClientCapability {
decision: Decision,
},
}
#[derive(Debug, Clone, PartialEq, Serialize, schemars::JsonSchema)]
#[serde(
tag = "kind",
rename_all = "snake_case",
rename_all_fields = "camelCase",
deny_unknown_fields
)]
pub enum InteractionOutcome {
ServiceApprovalDecision {
decision: Decision,
committed_at: u64,
},
PermissionsDecision {
decision: grant::Decision,
committed_at: u64,
},
QuestionAnswer {
answers: Vec<Option<String>>,
committed_at: u64,
},
FormAnswer {
#[serde(flatten)]
result: FormResult,
committed_at: u64,
},
ClientCapabilityDecision {
decision: Decision,
committed_at: u64,
},
Closure {
reason: ClosureReason,
committed_at: u64,
},
}
impl InteractionOutcome {
pub fn committed_at(&self) -> u64 {
match self {
Self::QuestionAnswer { committed_at, .. }
| Self::ServiceApprovalDecision { committed_at, .. }
| Self::PermissionsDecision { committed_at, .. }
| Self::FormAnswer { committed_at, .. }
| Self::ClientCapabilityDecision { committed_at, .. }
| Self::Closure { committed_at, .. } => *committed_at,
}
}
}
impl InteractionAnswer {
pub fn into_outcome(self, committed_at: u64) -> InteractionOutcome {
match self {
Self::ServiceApproval { decision } => InteractionOutcome::ServiceApprovalDecision {
decision,
committed_at,
},
Self::Permissions { decision } => InteractionOutcome::PermissionsDecision {
decision,
committed_at,
},
Self::Question { answers } => InteractionOutcome::QuestionAnswer {
answers,
committed_at,
},
Self::ClientCapability { decision } => InteractionOutcome::ClientCapabilityDecision {
decision,
committed_at,
},
Self::Form { result } => InteractionOutcome::FormAnswer {
result,
committed_at,
},
}
}
pub fn matches_outcome(&self, outcome: &InteractionOutcome) -> bool {
match (self, outcome) {
(
Self::ServiceApproval { decision: left },
InteractionOutcome::ServiceApprovalDecision {
decision: right, ..
},
) => left == right,
(
Self::Permissions { decision: left },
InteractionOutcome::PermissionsDecision {
decision: right, ..
},
) => left == right,
(
Self::Question { answers: left },
InteractionOutcome::QuestionAnswer { answers: right, .. },
) => left == right,
(
Self::ClientCapability { decision: left },
InteractionOutcome::ClientCapabilityDecision {
decision: right, ..
},
) => left == right,
(Self::Form { result: left }, InteractionOutcome::FormAnswer { result: right, .. }) => {
left == right
}
_ => false,
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct InteractionRecord {
pub session_id: String,
pub turn_id: String,
pub run_id: String,
pub request_id: String,
pub created_at: u64,
pub request: InteractionRequest,
#[schemars(with = "maka_plugin_core::wire_schema::RequiredNullable<InteractionOutcome>")]
pub outcome: Option<InteractionOutcome>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct SessionGrant {
pub session_id: String,
pub target: GrantTarget,
pub granted_at: u64,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub base_revision: Option<u64>,
}
/// The Host supplies the real operation description; it is display information,
/// not authority inferred from a command string or a plugin-supplied Session ID.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PermissionRequest {
pub reason: String,
pub command: Option<PermissionCommand>,
pub permissions: grant::Permissions,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub struct PermissionCommand {
pub command: String,
pub cwd: String,
}
impl PermissionRequest {
pub fn validate(&self) -> Result<(), &'static str> {
if self.reason.trim().is_empty() || self.reason.contains('\0') {
return Err("Permission request requires a reason");
}
if let Some(command) = &self.command
&& (command.command.trim().is_empty()
|| command.command.contains('\0')
|| !std::path::Path::new(&command.cwd).is_absolute()
|| command.cwd.contains('\0'))
{
return Err("Invalid permission command description");
}
self.permissions
.validate()
.map_err(|_| "Invalid additional permissions")?;
Ok(())
}
}