Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,703 changes: 2,270 additions & 433 deletions packages/plugin-sdk/src/contracts.ts

Large diffs are not rendered by default.

9 changes: 8 additions & 1 deletion platform/computer-use/src/desktop/worker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,7 @@ enum Failure {
impl From<ToolError> for Failure {
fn from(error: ToolError) -> Self {
match error {
ToolError::PermissionDenied(denial) => Self::Failed(denial.message),
ToolError::Failed(message) => Self::Failed(message),
ToolError::Native(error) => Self::Native(error),
ToolError::OutcomeUnknown(message) | ToolError::OperationUnknown { message, .. } => {
Expand Down Expand Up @@ -572,7 +573,13 @@ impl Process {

fn authorization_cleanup(result: Result<(), ToolError>) -> Option<ToolError> {
match result {
Ok(()) | Err(ToolError::Failed(_) | ToolError::Native(_) | ToolError::Io { .. }) => None,
Ok(())
| Err(
ToolError::Failed(_)
| ToolError::PermissionDenied(_)
| ToolError::Native(_)
| ToolError::Io { .. },
) => None,
Err(error) => {
let message = format!("Browser authorization cleanup was not confirmed: {error}");
Some(match error {
Expand Down
60 changes: 52 additions & 8 deletions plugins/agent/authority/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ use maka_agent_facts::interaction::InteractionRecord;
use maka_agent_facts::interaction::InteractionRequest;
use maka_agent_facts::interaction::PermissionDecision;
use maka_agent_facts::interaction::PermissionRequest;
use maka_agent_facts::tools::ToolError;
use maka_agent_facts::tools::{PermissionDenial, PermissionDenialReason, ToolError};
use maka_event_log::{StoreError, sessions::SessionRecord};
use sha2::{Digest, Sha256};
use tokio_util::sync::CancellationToken;
Expand Down Expand Up @@ -66,7 +66,10 @@ pub async fn request(
) -> Result<PermissionGrant, ToolError> {
let configuration = access.live_configuration().await?;
if !configuration.permissions.request_permissions {
return Err(failed(
return Err(denied(
PermissionDenialReason::RequestsDisabled,
&request.permissions,
request.command.as_ref(),
"Workspace policy does not allow requesting additional permissions",
));
}
Expand Down Expand Up @@ -103,7 +106,7 @@ pub async fn request(
)),
},
basis: configuration.permission_basis(),
request,
request: request.clone(),
},
outcome: None,
};
Expand All @@ -126,22 +129,63 @@ pub async fn request(
if cancellation.is_cancelled() {
return Err(failed("Permission request cancelled"));
}
if !matches!(
outcome,
match outcome {
InteractionOutcome::PermissionsDecision {
decision: PermissionDecision::Allow { .. },
..
} => {}
InteractionOutcome::PermissionsDecision {
decision: PermissionDecision::Deny,
..
} => {
return Err(denied(
PermissionDenialReason::UserDenied,
&request.permissions,
request.command.as_ref(),
"User denied additional permissions. Do not repeat this request; continue within existing permissions or report the blocked operation.",
));
}
_ => {
let current = access.live_configuration().await?;
if current.permission_basis() != configuration.permission_basis() {
return Err(denied(
PermissionDenialReason::GrantExpired,
&request.permissions,
request.command.as_ref(),
"Permission authority changed while awaiting approval",
));
}
return Err(failed("Permission request closed without a decision"));
}
) {
return Err(failed("Additional permissions were not granted"));
}
access
.permission_grants(target, &configuration.permission_basis())
.await
.map_err(|_| failed("Permission observation unavailable"))?
.into_iter()
.find(|grant| grant.request_id == record.request_id)
.ok_or_else(|| failed("Approved permissions are no longer current"))
.ok_or_else(|| {
denied(
PermissionDenialReason::GrantExpired,
&request.permissions,
request.command.as_ref(),
"Approved permissions are no longer current",
)
})
}

pub fn denied(
reason: PermissionDenialReason,
permissions: &maka_sandbox::grant::Permissions,
command: Option<&maka_agent_facts::interaction::PermissionCommand>,
message: impl Into<String>,
) -> ToolError {
ToolError::PermissionDenied(Box::new(PermissionDenial {
reason,
permissions: permissions.clone(),
command: command.cloned(),
message: message.into(),
}))
}

fn failed(message: impl Into<String>) -> ToolError {
Expand Down
26 changes: 26 additions & 0 deletions plugins/agent/facts/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -91,8 +91,34 @@ pub struct NativeToolError {
pub message: String,
}

/// A Host decision made before the requested effect starts. Never inferred from stderr.
#[derive(
Clone, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema, thiserror::Error,
)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
#[error("{message}")]
pub struct PermissionDenial {
pub reason: PermissionDenialReason,
pub permissions: maka_sandbox::grant::Permissions,
#[serde(skip_serializing_if = "Option::is_none")]
pub command: Option<crate::interaction::PermissionCommand>,
pub message: String,
}

#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum PermissionDenialReason {
RequestsDisabled,
UserDenied,
ProtectedBoundary,
ApprovalInsufficient,
GrantExpired,
}

#[derive(Clone, Debug, thiserror::Error)]
pub enum ToolError {
#[error(transparent)]
PermissionDenied(Box<PermissionDenial>),
/// An ordinary tool failure with a known outcome.
#[error("tool failed: {0}")]
Failed(String),
Expand Down
5 changes: 4 additions & 1 deletion plugins/agent/native/src/owner/auxiliary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,10 @@ impl Auxiliary {
}
Err(Error::Admission(_))
| Err(Error::Tool(
ToolError::Failed(_) | ToolError::Native(_) | ToolError::Io { .. },
ToolError::PermissionDenied(_)
| ToolError::Failed(_)
| ToolError::Native(_)
| ToolError::Io { .. },
)) => Outcome::Error,
};
custody.journal().settle(outcome, timestamp()?).await.map_err(|error| {
Expand Down
1 change: 1 addition & 0 deletions plugins/agent/native/src/owner/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,6 +549,7 @@ impl Owner {
};
let tool_error_variant = match error {
RunError::Tool(error) => match error {
ToolError::PermissionDenied(_) => "permission_denied",
ToolError::Failed(_) => "failed",
ToolError::Native(_) => "native",
ToolError::Io { .. } => "io",
Expand Down
2 changes: 2 additions & 0 deletions plugins/workspace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@ Workspace services can be composed independently of the Agent engine. Each leaf
- Computer Use exposes `cua_repl` and `cua_reset` through the [native provider](workspace-computer/native/src/lib.rs). Each conversation needs explicit access approval. Changing its permission boundary invalidates that grant; Explore mode cannot operate the computer. REPL variables and target bindings last until reset or Host shutdown, while the conversation grant survives restart. Desktop and browser connections are selected in the provider configuration.

The shared [API](api/README.md) defines workspace contracts. [Support](support/README.md) handles configured roots, path resolution and private-path exclusions. A service receives its resource limits and authority explicitly; a path in request JSON cannot widen them.

Files and Processes return `permission_denied` for a confirmed Host refusal before execution, preserving the target permissions, command and reason. The reasons distinguish disabled requests, explicit user denial, protected boundaries, insufficient approval and expired grants. Cancellation and observation failures are separate errors. Process status and exit evidence remain independent of permission reasons; stderr containing `EPERM` alone is not evidence of a sandbox refusal.
2 changes: 2 additions & 0 deletions plugins/workspace/README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,5 @@
- Computer Use 通过[原生提供方](workspace-computer/native/src/lib.rs)提供 `cua_repl` 和 `cua_reset`。每个会话需要显式授权,权限边界变更会使授权失效;Explore 模式不能操作电脑。REPL 变量和目标绑定保留到重置或 Host 关闭,会话授权则在重启后保留。桌面与浏览器连接由提供方配置选择。

共用 [API](api/README.zh-CN.md)定义工作区契约,[support](support/README.zh-CN.md)处理已配置根目录、路径解析和私有路径排除。服务显式接收资源限制与权限,请求 JSON 中的路径不能扩大它们。

Files 与 Processes 对执行前确定的 Host 权限拒绝返回 `permission_denied`,保留目标权限、命令和原因,区分禁止申请、用户明确拒绝、保护边界、批准不足及授权失效。取消和观察失败使用独立错误。进程状态及退出证据与权限原因分开表达;仅 stderr 中出现 `EPERM` 不构成沙箱拒绝的证据。
5 changes: 5 additions & 0 deletions plugins/workspace/api/src/files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,11 @@ pub const EXPORT: &str = "files";
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[schemars(rename = "WorkspaceFileResult")]
pub enum FileResult {
PermissionDenied {
#[serde(rename = "executionState")]
execution_state: crate::NotStarted,
denial: Box<maka_agent_facts::tools::PermissionDenial>,
},
Completed {
value: Value,
},
Expand Down
6 changes: 6 additions & 0 deletions plugins/workspace/api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,9 @@ pub mod files;
pub mod processes;
pub mod protocol;
pub mod terminal;

#[derive(serde::Serialize, schemars::JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum NotStarted {
NotStarted,
}
13 changes: 11 additions & 2 deletions plugins/workspace/api/src/processes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,17 @@ pub fn configuration_schema() -> Value {
#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)]
#[schemars(rename = "WorkspaceProcessResult")]
pub enum ProcessResult {
Completed { value: Value },
Failed { message: String },
PermissionDenied {
#[serde(rename = "executionState")]
execution_state: crate::NotStarted,
denial: Box<maka_agent_facts::tools::PermissionDenial>,
},
Completed {
value: Value,
},
Failed {
message: String,
},
}
#[derive(Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
Expand Down
5 changes: 5 additions & 0 deletions plugins/workspace/workspace-files/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,6 +181,11 @@ impl Handler for Files {
Err(error) if name == "readImage" => {
return Err(HandlerError::from(error.to_string()));
}
Err(ToolError::PermissionDenied(denial)) => FileResult::PermissionDenied {
execution_state: maka_workspace_api::NotStarted::NotStarted,
denial,
},

Err(ToolError::Failed(message) | ToolError::Io { message, .. }) => {
FileResult::Failed { message }
}
Expand Down
16 changes: 13 additions & 3 deletions plugins/workspace/workspace-files/native/src/permissions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

use super::*;
use maka_agent_facts::interaction::PermissionRequest;
use maka_agent_facts::tools::PermissionDenialReason;
use std::path::Component;

pub(super) async fn authorize(
Expand Down Expand Up @@ -56,7 +57,10 @@ pub(super) async fn authorize(
network: Network::Denied,
};
if !captured.ceiling.permits(&lexical).map_err(failed)? {
return Err(failed(
return Err(maka_agent_authority::permissions::denied(
PermissionDenialReason::ProtectedBoundary,
&lexical,
None,
"Workspace metadata or Host-private files cannot be granted",
));
}
Expand Down Expand Up @@ -127,7 +131,10 @@ pub(super) async fn authorize(
network: Network::Denied,
};
if !captured.ceiling.permits(&permissions).map_err(failed)? {
return Err(failed(
return Err(maka_agent_authority::permissions::denied(
PermissionDenialReason::ProtectedBoundary,
&permissions,
None,
"Workspace metadata or Host-private files cannot be granted",
));
}
Expand Down Expand Up @@ -165,7 +172,10 @@ pub(super) async fn authorize(
.and_then(|sandbox| sandbox.intersect(&floor))
.map_err(failed)?;
if !captured.sandbox.permits(&required).map_err(failed)? {
return Err(failed(
return Err(maka_agent_authority::permissions::denied(
PermissionDenialReason::ApprovalInsufficient,
&required,
None,
"Not all target files were approved; no files were changed",
));
}
Expand Down
12 changes: 9 additions & 3 deletions plugins/workspace/workspace-processes/native/src/execute.rs
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,10 @@ impl Processes {
.map_err(failed)?
.map_err(failed)?;
if !captured.ceiling.permits(&required).map_err(failed)? {
return Err(failed(
return Err(maka_agent_authority::permissions::denied(
maka_agent_facts::tools::PermissionDenialReason::ProtectedBoundary,
&required,
requested.command.as_ref(),
"Requested access exceeds the process installation or protected workspace boundary",
));
}
Expand All @@ -328,7 +331,7 @@ impl Processes {
access,
PermissionRequest {
reason: requested.reason,
command: requested.command,
command: requested.command.clone(),
permissions: required.clone(),
},
access.target(),
Expand All @@ -340,7 +343,10 @@ impl Processes {
.map_err(failed)?;
}
if !sandbox.permits(&required).map_err(failed)? {
return Err(failed(
return Err(maka_agent_authority::permissions::denied(
maka_agent_facts::tools::PermissionDenialReason::ApprovalInsufficient,
&required,
requested.command.as_ref(),
"Not all requested process permissions were approved; command was not started",
));
}
Expand Down
4 changes: 4 additions & 0 deletions plugins/workspace/workspace-processes/native/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,10 @@ impl Handler for Service {
ticket.complete(cleanup);
let result = match result {
Ok(value) => ProcessResult::Completed { value },
Err(ToolError::PermissionDenied(denial)) => ProcessResult::PermissionDenied {
execution_state: maka_workspace_api::NotStarted::NotStarted,
denial,
},
Err(ToolError::Failed(message) | ToolError::Io { message, .. }) => {
ProcessResult::Failed { message }
}
Expand Down