Skip to content
Merged
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
152 changes: 152 additions & 0 deletions crates/domain/src/approval.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
//! Confirmation holds for already-allowed mutations. Not a privilege grant.

use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::ids::ApprovalId;
use crate::patch::ApplyPatchResult;
use crate::process::ExecCommandResult;

/// Operator workspace setting. Not an MCP argument that grants rights.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum ApprovalsMode {
/// Mutations run immediately after policy allows them.
#[default]
Off,
/// Policy-allowed `apply_patch` / `exec_command` wait on a confirmation hold.
Confirm,
}

impl ApprovalsMode {
pub fn holds_mutations(self) -> bool {
matches!(self, Self::Confirm)
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub enum ApprovalTargetTool {
#[serde(rename = "apply_patch")]
ApplyPatch,
#[serde(rename = "exec_command")]
ExecCommand,
}

impl ApprovalTargetTool {
pub fn as_str(self) -> &'static str {
match self {
Self::ApplyPatch => "apply_patch",
Self::ExecCommand => "exec_command",
}
}

pub fn parse(value: &str) -> Result<Self, String> {
match value {
"apply_patch" => Ok(Self::ApplyPatch),
"exec_command" => Ok(Self::ExecCommand),
other => Err(format!("unsupported approval tool `{other}`")),
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalState {
Pending,
Granted,
/// Resume has been claimed. Execution may be in flight or interrupted.
Resuming,
Denied,
Consumed,
}

impl ApprovalState {
pub fn as_str(self) -> &'static str {
match self {
Self::Pending => "pending",
Self::Granted => "granted",
Self::Resuming => "resuming",
Self::Denied => "denied",
Self::Consumed => "consumed",
}
}

pub fn parse(value: &str) -> Result<Self, String> {
match value {
"pending" => Ok(Self::Pending),
"granted" => Ok(Self::Granted),
"resuming" => Ok(Self::Resuming),
"denied" => Ok(Self::Denied),
"consumed" => Ok(Self::Consumed),
other => Err(format!("unknown approval state `{other}`")),
}
}
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum ApprovalDecision {
Grant,
Deny,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ApprovalCreateParams {
pub tool: ApprovalTargetTool,
/// Same argument object as the named tool.
pub arguments: serde_json::Value,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ApprovalCreateResult {
pub approval_id: ApprovalId,
pub state: ApprovalState,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct ApprovalResolveParams {
pub approval_id: ApprovalId,
pub decision: ApprovalDecision,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct ApprovalResolveResult {
pub approval_id: ApprovalId,
pub state: ApprovalState,
}

#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct OperationResumeParams {
pub approval_id: ApprovalId,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
pub struct OperationResumeResult {
pub approval_id: ApprovalId,
pub state: ApprovalState,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apply_patch: Option<ApplyPatchResult>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub exec_command: Option<ExecCommandResult>,
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn approvals_mode_defaults_to_off() {
assert_eq!(ApprovalsMode::default(), ApprovalsMode::Off);
assert!(!ApprovalsMode::Off.holds_mutations());
assert!(ApprovalsMode::Confirm.holds_mutations());
assert_eq!(
serde_json::to_value(ApprovalsMode::Confirm).unwrap(),
"confirm"
);
assert_eq!(ApprovalState::Resuming.as_str(), "resuming");
assert_eq!(
ApprovalState::parse("resuming").unwrap(),
ApprovalState::Resuming
);
}
}
20 changes: 20 additions & 0 deletions crates/domain/src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@ pub enum ErrorCode {
IntentNotEditable,
IntentRevisionConflict,
QueueNotEmpty,
ApprovalRequired,
ApprovalNotFound,
ApprovalConflict,
ApprovalAmbiguous,
}

#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
Expand All @@ -41,6 +45,8 @@ pub struct ErrorBody {
pub message: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub operation_id: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub approval_id: Option<String>,
}

impl ErrorBody {
Expand All @@ -49,13 +55,19 @@ impl ErrorBody {
code,
message: message.into(),
operation_id: None,
approval_id: None,
}
}

pub fn with_operation_id(mut self, id: impl Into<String>) -> Self {
self.operation_id = Some(id.into());
self
}

pub fn with_approval_id(mut self, id: impl Into<String>) -> Self {
self.approval_id = Some(id.into());
self
}
}

/// A lost HTTP response, TCP reset, or 401 at the Bearer layer is not an
Expand Down Expand Up @@ -108,6 +120,14 @@ mod tests {
assert_eq!(json, "\"INTENT_ALREADY_CLAIMED\"");
let json = serde_json::to_string(&ErrorCode::WorkClosed).unwrap();
assert_eq!(json, "\"WORK_CLOSED\"");
let json = serde_json::to_string(&ErrorCode::ApprovalRequired).unwrap();
assert_eq!(json, "\"APPROVAL_REQUIRED\"");
let json = serde_json::to_string(&ErrorCode::ApprovalNotFound).unwrap();
assert_eq!(json, "\"APPROVAL_NOT_FOUND\"");
let json = serde_json::to_string(&ErrorCode::ApprovalConflict).unwrap();
assert_eq!(json, "\"APPROVAL_CONFLICT\"");
let json = serde_json::to_string(&ErrorCode::ApprovalAmbiguous).unwrap();
assert_eq!(json, "\"APPROVAL_AMBIGUOUS\"");
}

#[test]
Expand Down
6 changes: 6 additions & 0 deletions crates/domain/src/execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};

use crate::approval::ApprovalsMode;
use crate::error::ErrorCode;

/// Advertised PTY size. Must match the isolated PTY adapter default.
Expand Down Expand Up @@ -152,6 +153,9 @@ pub struct WorkspaceExecutionInfo {
pub serialization: WorkspaceSerializationInfo,
pub isolation: IsolationInfo,
pub network: NetworkInfo,
/// Operator confirmation-hold setting. Does not grant extra rights.
#[serde(default)]
pub approvals: ApprovalsMode,
}

impl WorkspaceExecutionInfo {
Expand Down Expand Up @@ -215,6 +219,7 @@ impl WorkspaceExecutionInfo {
enforcement: NetworkEnforcementState::None,
client_may_escalate: false,
},
approvals: ApprovalsMode::Off,
}
}

Expand Down Expand Up @@ -399,6 +404,7 @@ mod tests {
assert_files(&exec, false, false, false);
assert_process_unavailable(&exec);
let json = serde_json::to_value(&exec).unwrap();
assert_eq!(json["approvals"], "off");
assert_eq!(json["environment"]["kind"], "linux-container");
assert!(json.get("environment_id").is_none());
assert!(!json.to_string().contains("\"environment_id\""));
Expand Down
4 changes: 4 additions & 0 deletions crates/domain/src/ids.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ pub struct WorkId(pub String);
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct IntentId(pub String);

/// Server-minted id for a confirmation hold. Distinct from [`OperationId`].
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize, JsonSchema)]
pub struct ApprovalId(pub String);

#[cfg(test)]
mod tests {
use super::*;
Expand Down
11 changes: 9 additions & 2 deletions crates/domain/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Domain types for CodeSpace. No `rmcp` dependency.

pub mod approval;
pub mod error;
pub mod execution;
pub mod files;
Expand All @@ -12,6 +13,11 @@ pub mod profile;
pub mod tools;
pub mod work;

pub use approval::{
ApprovalCreateParams, ApprovalCreateResult, ApprovalDecision, ApprovalResolveParams,
ApprovalResolveResult, ApprovalState, ApprovalTargetTool, ApprovalsMode, OperationResumeParams,
OperationResumeResult,
};
pub use error::{
classify_http_status, ErrorBody, ErrorCode, FailureClass, TRANSPORT_FAILURE_IS_NOT_OPERATION,
};
Expand All @@ -22,7 +28,7 @@ pub use execution::{
WorkspaceExecutionInfo, WorkspaceSerializationInfo, PTY_INITIAL_COLS, PTY_INITIAL_ROWS,
};
pub use files::{FindParams, FindResult, ReadParams, ReadResult};
pub use ids::{IntentId, OperationId, OperationKey, ProcessId, WorkId, WorkspaceId};
pub use ids::{ApprovalId, IntentId, OperationId, OperationKey, ProcessId, WorkId, WorkspaceId};
pub use info::{workspace_info, WorkspaceInfo, WorkspaceInfoParams};
pub use intent::{DeliveryPolicy, IntentKind, IntentState, UserIntent};
pub use patch::{
Expand All @@ -35,7 +41,8 @@ pub use process::{
};
pub use profile::Profile;
pub use tools::{
LIVE_TOOLS, SERVER_NAME, SERVER_VERSION, TOOL_APPLY_PATCH, TOOL_EXEC_COMMAND, TOOL_FIND,
LIVE_TOOLS, SERVER_NAME, SERVER_VERSION, TOOL_APPLY_PATCH, TOOL_APPROVAL_CREATE,
TOOL_APPROVAL_RESOLVE, TOOL_EXEC_COMMAND, TOOL_FIND, TOOL_OPERATION_RESUME,
TOOL_OPERATION_STATUS, TOOL_READ, TOOL_READ_PROCESS, TOOL_STEER_CLAIM_NEXT,
TOOL_STEER_COMPLETE, TOOL_STEER_STATUS, TOOL_TERMINATE_PROCESS, TOOL_WORKSPACE_INFO,
TOOL_WORK_FINISH, TOOL_WORK_OPEN, TOOL_WRITE_STDIN, TRANSPORT_STDIO, TRANSPORT_STREAMABLE_HTTP,
Expand Down
9 changes: 8 additions & 1 deletion crates/domain/src/tools.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,15 @@ pub const TOOL_STEER_STATUS: &str = "steer_status";
pub const TOOL_STEER_CLAIM_NEXT: &str = "steer_claim_next";
pub const TOOL_STEER_COMPLETE: &str = "steer_complete";
pub const TOOL_WORK_FINISH: &str = "work_finish";
pub const TOOL_APPROVAL_CREATE: &str = "approval_create";
pub const TOOL_APPROVAL_RESOLVE: &str = "approval_resolve";
pub const TOOL_OPERATION_RESUME: &str = "operation_resume";

pub const TRANSPORT_STDIO: &str = "stdio";
pub const TRANSPORT_STREAMABLE_HTTP: &str = "streamable-http";

/// Tools registered in this release.
/// Tools registered in this release. Contract tests require `tools/list` to
/// match this list.
pub const LIVE_TOOLS: &[&str] = &[
TOOL_WORKSPACE_INFO,
TOOL_READ,
Expand All @@ -35,6 +39,9 @@ pub const LIVE_TOOLS: &[&str] = &[
TOOL_STEER_CLAIM_NEXT,
TOOL_STEER_COMPLETE,
TOOL_WORK_FINISH,
TOOL_APPROVAL_CREATE,
TOOL_APPROVAL_RESOLVE,
TOOL_OPERATION_RESUME,
];
pub const W03_EXPOSED_TOOLS: &[&str] = LIVE_TOOLS;

Expand Down
19 changes: 18 additions & 1 deletion crates/policy/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ use std::collections::BTreeMap;
use std::fs;
use std::path::{Component, Path, PathBuf};

use codespace_domain::{ErrorBody, ErrorCode, Profile, WorkspaceId};
use codespace_domain::{ApprovalsMode, ErrorBody, ErrorCode, Profile, WorkspaceId};
use serde::{Deserialize, Serialize};

pub use environment::{
Expand Down Expand Up @@ -44,6 +44,9 @@ pub struct Workspace {
/// Operator JSON, like `environment`. Not an MCP tool field.
#[serde(default)]
pub network: NetworkAxis,
/// Operator JSON confirmation hold. Not an MCP tool field and not a grant.
#[serde(default)]
pub approvals: ApprovalsMode,
}

fn default_environment_id() -> String {
Expand All @@ -59,6 +62,7 @@ impl Workspace {
environment_id: DEFAULT_ENVIRONMENT_ID.to_string(),
environment_kind: EnvironmentKind::Host,
network: NetworkAxis::Restricted,
approvals: ApprovalsMode::Off,
}
}

Expand Down Expand Up @@ -103,6 +107,8 @@ struct FileWorkspace {
environment: Option<String>,
#[serde(default)]
network: NetworkAxis,
#[serde(default)]
approvals: ApprovalsMode,
}

impl Registry {
Expand Down Expand Up @@ -175,6 +181,7 @@ impl Registry {
environment_id: environment.id.clone(),
environment_kind: environment.kind,
network: entry.network,
approvals: entry.approvals,
});
}
Ok(registry)
Expand Down Expand Up @@ -281,6 +288,16 @@ mod tests {
assert_eq!(ws.environment_id, DEFAULT_ENVIRONMENT_ID);
assert_eq!(ws.environment_kind, EnvironmentKind::Host);
assert_eq!(ws.network, NetworkAxis::Restricted);
assert_eq!(ws.approvals, ApprovalsMode::Off);
}

#[test]
fn approvals_confirm_is_operator_config() {
let json = r#"{"workspaces":{"demo":{"root":"/tmp/demo","profile":"workspace-write","approvals":"confirm"}}}"#;
let registry = Registry::load_json(json).unwrap();
let ws = registry.get("demo").unwrap();
assert_eq!(ws.approvals, ApprovalsMode::Confirm);
assert!(allow(ws, Action::Write, &ClientClaims::default()).is_ok());
}

#[test]
Expand Down
9 changes: 7 additions & 2 deletions crates/server/src/inbox.rs
Original file line number Diff line number Diff line change
Expand Up @@ -210,11 +210,16 @@ struct InboxError(ErrorBody);
impl IntoResponse for InboxError {
fn into_response(self) -> Response {
let status = match self.0.code {
ErrorCode::WorkNotFound | ErrorCode::IntentNotFound => StatusCode::NOT_FOUND,
ErrorCode::WorkNotFound | ErrorCode::IntentNotFound | ErrorCode::ApprovalNotFound => {
StatusCode::NOT_FOUND
}
ErrorCode::IntentAlreadyClaimed
| ErrorCode::IntentRevisionConflict
| ErrorCode::WorkClosed
| ErrorCode::QueueNotEmpty => StatusCode::CONFLICT,
| ErrorCode::QueueNotEmpty
| ErrorCode::ApprovalConflict
| ErrorCode::ApprovalRequired
| ErrorCode::ApprovalAmbiguous => StatusCode::CONFLICT,
ErrorCode::Unauthorized => StatusCode::FORBIDDEN,
_ => StatusCode::BAD_REQUEST,
};
Expand Down
Loading
Loading