diff --git a/crates/domain/src/approval.rs b/crates/domain/src/approval.rs new file mode 100644 index 0000000..0e62872 --- /dev/null +++ b/crates/domain/src/approval.rs @@ -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 { + 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 { + 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, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub exec_command: Option, +} + +#[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 + ); + } +} diff --git a/crates/domain/src/error.rs b/crates/domain/src/error.rs index c0d9f46..e8cf091 100644 --- a/crates/domain/src/error.rs +++ b/crates/domain/src/error.rs @@ -33,6 +33,10 @@ pub enum ErrorCode { IntentNotEditable, IntentRevisionConflict, QueueNotEmpty, + ApprovalRequired, + ApprovalNotFound, + ApprovalConflict, + ApprovalAmbiguous, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -41,6 +45,8 @@ pub struct ErrorBody { pub message: String, #[serde(skip_serializing_if = "Option::is_none")] pub operation_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub approval_id: Option, } impl ErrorBody { @@ -49,6 +55,7 @@ impl ErrorBody { code, message: message.into(), operation_id: None, + approval_id: None, } } @@ -56,6 +63,11 @@ impl ErrorBody { self.operation_id = Some(id.into()); self } + + pub fn with_approval_id(mut self, id: impl Into) -> Self { + self.approval_id = Some(id.into()); + self + } } /// A lost HTTP response, TCP reset, or 401 at the Bearer layer is not an @@ -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] diff --git a/crates/domain/src/execution.rs b/crates/domain/src/execution.rs index 0abdab9..12ec14a 100644 --- a/crates/domain/src/execution.rs +++ b/crates/domain/src/execution.rs @@ -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. @@ -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 { @@ -215,6 +219,7 @@ impl WorkspaceExecutionInfo { enforcement: NetworkEnforcementState::None, client_may_escalate: false, }, + approvals: ApprovalsMode::Off, } } @@ -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\"")); diff --git a/crates/domain/src/ids.rs b/crates/domain/src/ids.rs index f866fe7..1b90927 100644 --- a/crates/domain/src/ids.rs +++ b/crates/domain/src/ids.rs @@ -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::*; diff --git a/crates/domain/src/lib.rs b/crates/domain/src/lib.rs index 5cf2bf0..6fabfa2 100644 --- a/crates/domain/src/lib.rs +++ b/crates/domain/src/lib.rs @@ -1,5 +1,6 @@ //! Domain types for CodeSpace. No `rmcp` dependency. +pub mod approval; pub mod error; pub mod execution; pub mod files; @@ -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, }; @@ -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::{ @@ -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, diff --git a/crates/domain/src/tools.rs b/crates/domain/src/tools.rs index d3e4749..80fb4f1 100644 --- a/crates/domain/src/tools.rs +++ b/crates/domain/src/tools.rs @@ -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, @@ -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; diff --git a/crates/policy/src/lib.rs b/crates/policy/src/lib.rs index 6dfdedf..12d2fbc 100644 --- a/crates/policy/src/lib.rs +++ b/crates/policy/src/lib.rs @@ -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::{ @@ -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 { @@ -59,6 +62,7 @@ impl Workspace { environment_id: DEFAULT_ENVIRONMENT_ID.to_string(), environment_kind: EnvironmentKind::Host, network: NetworkAxis::Restricted, + approvals: ApprovalsMode::Off, } } @@ -103,6 +107,8 @@ struct FileWorkspace { environment: Option, #[serde(default)] network: NetworkAxis, + #[serde(default)] + approvals: ApprovalsMode, } impl Registry { @@ -175,6 +181,7 @@ impl Registry { environment_id: environment.id.clone(), environment_kind: environment.kind, network: entry.network, + approvals: entry.approvals, }); } Ok(registry) @@ -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] diff --git a/crates/server/src/inbox.rs b/crates/server/src/inbox.rs index b05eec5..8fb5a28 100644 --- a/crates/server/src/inbox.rs +++ b/crates/server/src/inbox.rs @@ -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, }; diff --git a/crates/server/src/mcp.rs b/crates/server/src/mcp.rs index 96f6493..0d39276 100644 --- a/crates/server/src/mcp.rs +++ b/crates/server/src/mcp.rs @@ -2,9 +2,11 @@ use std::borrow::Cow; use std::sync::Arc; use codespace_domain::{ - workspace_info, ApplyPatchParams, ApplyPatchResult, ClientEnvironmentKind, CoordinationHint, - EffectivePermissionInfo, EnvironmentExecutionInfo, ErrorBody, ErrorCode, ExecCommandParams, - ExecCommandResult, ExecDispatchStatus, FindParams, FindResult, NetworkPolicyState, + workspace_info, ApplyPatchParams, ApplyPatchResult, ApprovalCreateParams, ApprovalCreateResult, + ApprovalId, ApprovalResolveParams, ApprovalResolveResult, ApprovalState, ApprovalTargetTool, + ClientEnvironmentKind, CoordinationHint, EffectivePermissionInfo, EnvironmentExecutionInfo, + ErrorBody, ErrorCode, ExecCommandParams, ExecCommandResult, ExecDispatchStatus, FindParams, + FindResult, NetworkPolicyState, OperationResumeParams, OperationResumeResult, OperationStatusParams, OperationStatusResult, PatchStatus, ProcessId, ReadParams, ReadProcessParams, ReadProcessResult, ReadResult, SteerClaimNextResult, SteerCompleteParams, SteerStatusResult, TerminateProcessParams, WorkFinishResult, WorkId, WorkIdParams, @@ -19,7 +21,7 @@ use codespace_runner::{ linux_sandbox_available, Runner, RunnerApplyPatchRequest, RunnerError, RunnerExecRequest, RunnerReadProcess, RunnerWriteStdin, RuntimeBackend, }; -use codespace_store::{Begin, Store}; +use codespace_store::{Begin, ResumeClaim, Store, StoredOperation}; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{ @@ -78,6 +80,12 @@ permission. Treat apply_patch status=unknown as possibly executed. Do not blindly retry \ the mutation with a new operation_key. +When a workspace is configured with approvals=confirm, allowed apply_patch \ +and exec_command calls return APPROVAL_REQUIRED instead of executing. That \ +hold is a workflow pause, not a privilege grant or isolation boundary. The \ +same MCP caller can grant it with approval_resolve. Then call \ +operation_resume; resume re-checks policy. + If exec_command reports dispatch_status=unknown, the spawn may have occurred. \ Do not blindly start a duplicate process. The returned process_id identifies \ the uncertain attempt. Use read_process or terminate_process when the backend \ @@ -181,12 +189,21 @@ impl CodeSpace { #[tool( name = "apply_patch", - description = "Apply a Codex V4A patch. check_only verifies without writing and returns status checked. status applied means disk hashes match the helper claim. Never falls back to git apply. status=unknown means the mutation may have executed but its result could not be confirmed. Do not retry the same mutation under a new operation_key. operation_key provides replay/idempotency for the same logical mutation." + description = "Apply a Codex V4A patch. check_only verifies without writing and returns status checked. status applied means disk hashes match the helper claim. Never falls back to git apply. status=unknown means the mutation may have executed but its result could not be confirmed. Do not retry the same mutation under a new operation_key. operation_key provides replay/idempotency for the same logical mutation. When the workspace approvals mode is confirm, a policy-allowed request returns APPROVAL_REQUIRED before begin() and does not write." )] async fn apply_patch( &self, Parameters(params): Parameters, ) -> Result, String> { + let ws = self + .registry + .get(¶ms.workspace_id.0) + .map_err(err_json)?; + allow(ws, Action::Write, &ClientClaims::default()).map_err(err_json)?; + ws.require_file_write().map_err(err_json)?; + if let Err(err) = self.maybe_hold_patch(ws, ¶ms) { + return Err(err_json(err)); + } self.apply_patch_inner(params) .await .map(Json) @@ -209,7 +226,7 @@ impl CodeSpace { #[tool( name = "exec_command", - description = "Start a managed argv in the workspace cwd. There is no implicit shell. Returns a server-minted process_id and a dispatch_status. Request end does not terminate the process. Omitted or false tty uses pipes. tty=true attaches a fixed 24x80 PTY; resize is not supported. Use tty only for commands requiring terminal semantics or an interactive TUI. A live process holds the workspace mutation lease, so another exec_command or apply_patch may return WORKSPACE_BUSY until it exits or is terminated. Use write_stdin, read_process, and terminate_process with the returned process_id. dispatch_status=unknown means the spawn may have occurred. Do not blindly start a duplicate process. The returned process_id identifies the uncertain attempt. Use read_process or terminate_process when the backend remains reachable; do not assume that unknown means the process did not start. PROCESS_SPAWN_FAILED means the backend confirmed that no managed process was started; it is distinct from dispatch_status=unknown." + description = "Start a managed argv in the workspace cwd. There is no implicit shell. Returns a server-minted process_id and a dispatch_status. Request end does not terminate the process. Omitted or false tty uses pipes. tty=true attaches a fixed 24x80 PTY; resize is not supported. Use tty only for commands requiring terminal semantics or an interactive TUI. A live process holds the workspace mutation lease, so another exec_command or apply_patch may return WORKSPACE_BUSY until it exits or is terminated. Use write_stdin, read_process, and terminate_process with the returned process_id. dispatch_status=unknown means the spawn may have occurred. Do not blindly start a duplicate process. The returned process_id identifies the uncertain attempt. Use read_process or terminate_process when the backend remains reachable; do not assume that unknown means the process did not start. PROCESS_SPAWN_FAILED means the backend confirmed that no managed process was started; it is distinct from dispatch_status=unknown. When the workspace approvals mode is confirm, a policy-allowed request returns APPROVAL_REQUIRED before spawn." )] async fn exec_command( &self, @@ -222,34 +239,15 @@ impl CodeSpace { allow(ws, Action::Exec, &ClientClaims::default()).map_err(err_json)?; ws.require_exec().map_err(err_json)?; if params.command.is_empty() || params.command[0].is_empty() { - return Err(err_json(ErrorBody::new( - ErrorCode::InvalidCommand, - "command must be a non-empty argv (no shell)", - ))); + return Err(err_json(invalid_argv())); } - let process_id = ProcessId(format!("proc-{}", Uuid::new_v4())); - self.store - .mark_shell_busy(¶ms.workspace_id.0, &process_id.0) - .map_err(err_json)?; - let mut req = RunnerExecRequest::for_host(params.command, process_id.clone(), ws.profile); - req.policy.network = ws.network; - req.tty = params.tty; - match self.runner.exec(ws, req).await { - Ok(result) => Ok(Json(ExecCommandResult { - process_id: result.process_id, - dispatch_status: ExecDispatchStatus::Confirmed, - coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), - })), - Err(RunnerError::TransportAmbiguous { .. }) => Ok(Json(ExecCommandResult { - process_id, - dispatch_status: ExecDispatchStatus::Unknown, - coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), - })), - Err(err) => { - self.store.release_process(&process_id.0); - Err(runner_err_json(err)) - } + if let Err(err) = self.maybe_hold_exec(ws, ¶ms) { + return Err(err_json(err)); } + self.exec_command_inner(params) + .await + .map(Json) + .map_err(err_json) } #[tool( @@ -388,6 +386,47 @@ impl CodeSpace { .map(Json) .map_err(err_json) } + + #[tool( + name = "approval_create", + description = "Create a pending confirmation hold for an already-allowed apply_patch or exec_command. Policy denial returns UNAUTHORIZED and creates no row. This does not grant write/exec rights and does not change the profile. The same MCP caller can later grant the hold." + )] + async fn approval_create( + &self, + Parameters(params): Parameters, + ) -> Result, String> { + self.approval_create_inner(params) + .map(Json) + .map_err(err_json) + } + + #[tool( + name = "approval_resolve", + description = "Grant or deny a pending confirmation hold. grant does not change the permission profile and is not an isolation boundary; the same MCP caller can grant. deny is terminal. Resume a granted hold with operation_resume." + )] + async fn approval_resolve( + &self, + Parameters(params): Parameters, + ) -> Result, String> { + self.store + .resolve_approval(¶ms.approval_id, params.decision) + .map(Json) + .map_err(err_json) + } + + #[tool( + name = "operation_resume", + description = "Run a granted confirmation hold once after re-checking allow(). Repeating the same approval_id returns the stored terminal result, recovers a recorded patch from the operations ledger, or returns APPROVAL_AMBIGUOUS. This is not a privilege escalation." + )] + async fn operation_resume( + &self, + Parameters(params): Parameters, + ) -> Result, String> { + self.operation_resume_inner(params) + .await + .map(Json) + .map_err(err_json) + } } impl CodeSpace { @@ -478,6 +517,284 @@ impl CodeSpace { result.coordination = self.hint(workspace_id, work_id); result } + + fn maybe_hold_patch(&self, ws: &Workspace, params: &ApplyPatchParams) -> Result<(), ErrorBody> { + if !ws.approvals.holds_mutations() { + return Ok(()); + } + let snapshot = serde_json::to_value(params) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + let created = self.store.create_approval( + &ws.id.0, + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(params), + snapshot, + )?; + Err(approval_required(&created.approval_id)) + } + + fn maybe_hold_exec(&self, ws: &Workspace, params: &ExecCommandParams) -> Result<(), ErrorBody> { + if !ws.approvals.holds_mutations() { + return Ok(()); + } + let snapshot = serde_json::to_value(params) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidCommand, err.to_string()))?; + let created = self.store.create_approval( + &ws.id.0, + ApprovalTargetTool::ExecCommand, + &Store::exec_fingerprint(params), + snapshot, + )?; + Err(approval_required(&created.approval_id)) + } + + fn approval_create_inner( + &self, + params: ApprovalCreateParams, + ) -> Result { + match params.tool { + ApprovalTargetTool::ApplyPatch => { + let args: ApplyPatchParams = serde_json::from_value(params.arguments) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + let snapshot = serde_json::to_value(&args) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + let ws = self.registry.get(&args.workspace_id.0)?; + allow(ws, Action::Write, &ClientClaims::default())?; + ws.require_file_write()?; + let created = self.store.create_approval( + &ws.id.0, + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(&args), + snapshot, + )?; + Ok(ApprovalCreateResult { + approval_id: created.approval_id, + state: created.state, + }) + } + ApprovalTargetTool::ExecCommand => { + let args: ExecCommandParams = serde_json::from_value(params.arguments) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidCommand, err.to_string()))?; + if args.command.is_empty() || args.command[0].is_empty() { + return Err(invalid_argv()); + } + let snapshot = serde_json::to_value(&args) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidCommand, err.to_string()))?; + let ws = self.registry.get(&args.workspace_id.0)?; + allow(ws, Action::Exec, &ClientClaims::default())?; + ws.require_exec()?; + let created = self.store.create_approval( + &ws.id.0, + ApprovalTargetTool::ExecCommand, + &Store::exec_fingerprint(&args), + snapshot, + )?; + Ok(ApprovalCreateResult { + approval_id: created.approval_id, + state: created.state, + }) + } + } + } + + async fn operation_resume_inner( + &self, + params: OperationResumeParams, + ) -> Result { + match self.store.claim_resume(¶ms.approval_id)? { + ResumeClaim::ReplaySuccess(result) => Ok(result), + ResumeClaim::ReplayError(err) => Err(err), + ResumeClaim::Execute(record, _guard) => { + let outcome = self.execute_approved(record.clone()).await; + self.commit_resume(&record.approval_id, outcome) + } + ResumeClaim::Reconcile(record, _guard) => self.reconcile_resume(record).await, + } + } + + fn commit_resume( + &self, + approval_id: &ApprovalId, + outcome: Result, + ) -> Result { + match &outcome { + Ok(success) => match self.store.finish_resume(approval_id, Ok(success)) { + Ok(()) => Ok(success.clone()), + Err(mut err) => { + if err.approval_id.is_none() { + err = err.with_approval_id(approval_id.0.as_str()); + } + if err.operation_id.is_none() { + if let Some(op) = success.apply_patch.as_ref() { + err = err.with_operation_id(op.operation_id.0.clone()); + } + } + if err.code != ErrorCode::ApprovalAmbiguous { + err.code = ErrorCode::ApprovalAmbiguous; + err.message = + "mutation may have completed; resume result was not persisted".into(); + } + Err(err) + } + }, + Err(err) => match self.store.finish_resume(approval_id, Err(err)) { + Ok(()) => outcome, + Err(_) => outcome, + }, + } + } + + async fn reconcile_resume( + &self, + record: codespace_store::ApprovalRecord, + ) -> Result { + match record.tool { + ApprovalTargetTool::ApplyPatch => { + if let Some(stored) = self.lookup_patch_ledger(&record)? { + let result = OperationResumeResult { + approval_id: record.approval_id.clone(), + state: ApprovalState::Consumed, + apply_patch: Some(self.with_hint( + stored.result, + &record.workspace_id, + None, + )), + exec_command: None, + }; + return self.commit_resume(&record.approval_id, Ok(result)); + } + if params_scrubbed(&record.params_json) { + let err = ErrorBody::new( + ErrorCode::ApprovalAmbiguous, + "resume was interrupted and the snapshot is no longer available", + ) + .with_approval_id(record.approval_id.0.as_str()); + return self.commit_resume(&record.approval_id, Err(err)); + } + let outcome = self.execute_approved(record.clone()).await; + self.commit_resume(&record.approval_id, outcome) + } + ApprovalTargetTool::ExecCommand => { + let err = ErrorBody::new( + ErrorCode::ApprovalAmbiguous, + "exec resume is ambiguous after interruption; the process was not restarted", + ) + .with_approval_id(record.approval_id.0.as_str()); + self.commit_resume(&record.approval_id, Err(err)) + } + } + } + + fn lookup_patch_ledger( + &self, + record: &codespace_store::ApprovalRecord, + ) -> Result, ErrorBody> { + if let Ok(params) = serde_json::from_str::(&record.params_json) { + if let Some(key) = params.operation_key.as_ref() { + match self.store.status_lookup(None, Some(key)) { + Ok(status) => return Ok(Some(self.store.get(&status.operation_id)?)), + Err(err) if err.code == ErrorCode::OperationNotFound => {} + Err(err) => return Err(err), + } + } + return self.store.find_operation_by_fingerprint( + &record.workspace_id, + &Store::fingerprint(¶ms), + record.resolved_at, + ); + } + self.store.find_operation_by_fingerprint( + &record.workspace_id, + &record.fingerprint, + record.resolved_at, + ) + } + + async fn execute_approved( + &self, + record: codespace_store::ApprovalRecord, + ) -> Result { + match record.tool { + ApprovalTargetTool::ApplyPatch => { + let params: ApplyPatchParams = serde_json::from_str(&record.params_json) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()))?; + let result = self.apply_patch_inner(params).await?; + Ok(OperationResumeResult { + approval_id: record.approval_id, + state: ApprovalState::Consumed, + apply_patch: Some(result), + exec_command: None, + }) + } + ApprovalTargetTool::ExecCommand => { + let params: ExecCommandParams = serde_json::from_str(&record.params_json) + .map_err(|err| ErrorBody::new(ErrorCode::InvalidCommand, err.to_string()))?; + let result = self.exec_command_inner(params).await?; + Ok(OperationResumeResult { + approval_id: record.approval_id, + state: ApprovalState::Consumed, + apply_patch: None, + exec_command: Some(result), + }) + } + } + } + + async fn exec_command_inner( + &self, + params: ExecCommandParams, + ) -> Result { + let ws = self.registry.get(¶ms.workspace_id.0)?; + allow(ws, Action::Exec, &ClientClaims::default())?; + ws.require_exec()?; + if params.command.is_empty() || params.command[0].is_empty() { + return Err(invalid_argv()); + } + let process_id = ProcessId(format!("proc-{}", Uuid::new_v4())); + self.store + .mark_shell_busy(¶ms.workspace_id.0, &process_id.0)?; + let mut req = RunnerExecRequest::for_host(params.command, process_id.clone(), ws.profile); + req.policy.network = ws.network; + req.tty = params.tty; + match self.runner.exec(ws, req).await { + Ok(result) => Ok(ExecCommandResult { + process_id: result.process_id, + dispatch_status: ExecDispatchStatus::Confirmed, + coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), + }), + Err(RunnerError::TransportAmbiguous { .. }) => Ok(ExecCommandResult { + process_id, + dispatch_status: ExecDispatchStatus::Unknown, + coordination: self.hint(¶ms.workspace_id.0, params.work_id.as_ref()), + }), + Err(err) => { + self.store.release_process(&process_id.0); + Err(err.into_error_body()) + } + } + } +} + +fn invalid_argv() -> ErrorBody { + ErrorBody::new( + ErrorCode::InvalidCommand, + "command must be a non-empty argv (no shell)", + ) +} + +fn approval_required(id: &ApprovalId) -> ErrorBody { + ErrorBody::new( + ErrorCode::ApprovalRequired, + "confirmation is required before this allowed mutation can run", + ) + .with_approval_id(id.0.as_str()) +} + +fn params_scrubbed(raw: &str) -> bool { + serde_json::from_str::(raw) + .ok() + .and_then(|value| value.get("scrubbed").and_then(|flag| flag.as_bool())) + .unwrap_or(false) } fn runner_err_json(err: RunnerError) -> String { @@ -513,11 +830,12 @@ fn workspace_execution_info(ws: &Workspace) -> WorkspaceExecutionInfo { file_read_supported: ws.environment_kind.file_read_supported(), file_write_supported: ws.environment_kind.file_write_supported(), }; - let info = WorkspaceExecutionInfo::from_effective( + let mut info = WorkspaceExecutionInfo::from_effective( environment, permissions, client_network_policy(policy.network), ); + info.approvals = ws.approvals; advertise_linux_sandbox(info) } @@ -620,6 +938,7 @@ mod tests { assert_eq!(exec.permissions.write, policy.allows(Action::Write)); assert_eq!(exec.permissions.exec, policy.allows(Action::Exec)); assert_eq!(exec.network.policy, client_network_policy(policy.network)); + assert_eq!(exec.approvals, ws.approvals); assert!(!exec.network.client_may_escalate); assert_eq!( exec.environment.exec_supported, @@ -708,6 +1027,11 @@ mod tests { "{text}" ); assert!(text.contains("major checkpoints"), "{text}"); + assert!(text.contains("approvals=confirm"), "{text}"); + assert!(text.contains("APPROVAL_REQUIRED"), "{text}"); + assert!(text.contains("not a privilege grant"), "{text}"); + assert!(text.contains("same MCP caller can grant"), "{text}"); + assert!(text.contains("isolation boundary"), "{text}"); } #[test] diff --git a/crates/server/tests/approvals.rs b/crates/server/tests/approvals.rs new file mode 100644 index 0000000..f9f15c6 --- /dev/null +++ b/crates/server/tests/approvals.rs @@ -0,0 +1,933 @@ +use codespace_domain::{ + ApprovalId, ApprovalState, TOOL_APPLY_PATCH, TOOL_APPROVAL_CREATE, TOOL_APPROVAL_RESOLVE, + TOOL_EXEC_COMMAND, TOOL_OPERATION_RESUME, TOOL_OPERATION_STATUS, TOOL_WORKSPACE_INFO, +}; +use rmcp::{ + model::CallToolRequestParams, + object, + transport::{ConfigureCommandExt, TokioChildProcess}, + ServiceExt, +}; +use tokio::process::Command; + +fn payload(result: &rmcp::model::CallToolResult) -> serde_json::Value { + result.structured_content.clone().unwrap_or_else(|| { + serde_json::from_str(&result.content[0].as_text().unwrap().text).unwrap() + }) +} + +fn err_text(result: &Result) -> String { + match result { + Ok(r) => r + .content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect::>() + .join(""), + Err(err) => err.to_string(), + } +} + +fn error_body( + result: &Result, +) -> serde_json::Value { + let text = err_text(result); + if let Ok(value) = serde_json::from_str::(&text) { + return value; + } + let start = text.find('{').unwrap_or_else(|| panic!("{text}")); + let end = text.rfind('}').unwrap_or_else(|| panic!("{text}")); + serde_json::from_str(&text[start..=end]).unwrap_or_else(|_| panic!("{text}")) +} + +fn write_workspace( + profile: &str, + approvals: Option<&str>, +) -> (tempfile::TempDir, std::path::PathBuf, std::path::PathBuf) { + let root = tempfile::tempdir().unwrap(); + let ws = root.path().join("ws"); + std::fs::create_dir(&ws).unwrap(); + let cfg = root.path().join("workspaces.json"); + let mut demo = serde_json::json!({ + "root": ws, + "profile": profile + }); + if let Some(mode) = approvals { + demo["approvals"] = serde_json::Value::String(mode.to_string()); + } + std::fs::write( + &cfg, + serde_json::json!({ "workspaces": { "demo": demo } }).to_string(), + ) + .unwrap(); + (root, cfg, ws) +} + +async fn spawn_client( + cfg: &std::path::Path, + db: &std::path::Path, +) -> rmcp::service::RunningService { + let bin = env!("CARGO_BIN_EXE_codespace-mcp"); + let helper = codespace_server::patch_helper::ensure_helper_for_tests(); + ().serve( + TokioChildProcess::new(Command::new(bin).configure(|cmd| { + cmd.env("CODESPACE_CONFIG", cfg) + .env("CODESPACE_OPERATIONS_DB", db) + .env("CODESPACE_PATCH_BIN", helper); + })) + .expect("spawn"), + ) + .await + .expect("init") +} + +const ADD_PATCH: &str = "*** Begin Patch\n*** Add File: created.txt\n+hello\n*** End Patch\n"; + +#[tokio::test] +async fn read_only_create_and_resume_cannot_open_write_or_exec() { + let (root, cfg, ws) = write_workspace("read-only", None); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let create = client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_CREATE).with_arguments(object!({ + "tool": "apply_patch", + "arguments": { + "workspace_id": "demo", + "patch": ADD_PATCH, + "approved": true, + "network": true + } + })), + ) + .await; + let created = error_body(&create); + assert_eq!(created["code"], "UNAUTHORIZED"); + assert!(!ws.join("created.txt").exists()); + + let apply = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "approved": true + })), + ) + .await; + assert_eq!(error_body(&apply)["code"], "UNAUTHORIZED"); + assert!(!ws.join("created.txt").exists()); + + let exec = client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_CREATE).with_arguments(object!({ + "tool": "exec_command", + "arguments": { + "workspace_id": "demo", + "command": ["/bin/echo", "nope"], + "approved": true + } + })), + ) + .await; + assert_eq!(error_body(&exec)["code"], "UNAUTHORIZED"); + + let resume = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME).with_arguments(object!({ + "approval_id": "appr-missing" + })), + ) + .await; + assert_eq!(error_body(&resume)["code"], "APPROVAL_NOT_FOUND"); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn confirm_apply_holds_then_grant_resume_writes_once() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let info = client + .call_tool( + CallToolRequestParams::new(TOOL_WORKSPACE_INFO) + .with_arguments(object!({ "workspace_id": "demo" })), + ) + .await + .expect("info"); + assert_eq!(payload(&info)["execution"]["approvals"], "confirm"); + + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "hold-1" + })), + ) + .await; + let body = error_body(&held); + assert_eq!(body["code"], "APPROVAL_REQUIRED"); + let approval_id = body["approval_id"] + .as_str() + .expect("approval_id") + .to_string(); + assert!(approval_id.starts_with("appr-")); + assert!(!ws.join("created.txt").exists()); + + let granted = client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": approval_id, + "decision": "grant" + })), + ) + .await + .expect("grant"); + assert_eq!(payload(&granted)["state"], "granted"); + assert!(!ws.join("created.txt").exists()); + + let resumed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume"); + let resume_body = payload(&resumed); + assert_eq!(resume_body["state"], "consumed"); + assert_eq!(resume_body["apply_patch"]["status"], "applied"); + let operation_id = resume_body["apply_patch"]["operation_id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + + let replay = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("replay"); + assert_eq!( + payload(&replay)["apply_patch"]["operation_id"], + operation_id + ); + + let status = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_STATUS) + .with_arguments(object!({ "operation_id": operation_id })), + ) + .await + .expect("status"); + let status_body = payload(&status); + assert_eq!(status_body["kind"], "patch"); + assert_eq!(status_body["status"], "applied"); + assert_eq!(status_body["files"][0], "created.txt"); + + let second = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: other.txt\n+x\n*** End Patch\n" + })), + ) + .await; + assert_eq!(error_body(&second)["code"], "APPROVAL_REQUIRED"); + assert!(!ws.join("other.txt").exists()); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn deny_then_resume_fails() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + + let denied = client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": approval_id, + "decision": "deny" + })), + ) + .await + .expect("deny"); + assert_eq!(payload(&denied)["state"], "denied"); + + let resume = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await; + assert_eq!(error_body(&resume)["code"], "APPROVAL_CONFLICT"); + assert!(!ws.join("created.txt").exists()); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn off_apply_still_runs_immediately() { + let (root, cfg, ws) = write_workspace("workspace-write", None); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let info = client + .call_tool( + CallToolRequestParams::new(TOOL_WORKSPACE_INFO) + .with_arguments(object!({ "workspace_id": "demo" })), + ) + .await + .expect("info"); + assert_eq!(payload(&info)["execution"]["approvals"], "off"); + + let applied = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "immediate" + })), + ) + .await + .expect("apply"); + assert_eq!(payload(&applied)["status"], "applied"); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn confirm_exec_holds_until_resume() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/sh", "-c", "printf x > held.txt"] + })), + ) + .await; + let body = error_body(&held); + assert_eq!(body["code"], "APPROVAL_REQUIRED"); + assert!(!ws.join("held.txt").exists()); + let approval_id = body["approval_id"].as_str().unwrap().to_string(); + + client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": approval_id, + "decision": "grant" + })), + ) + .await + .expect("grant"); + + let resumed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume"); + let resume_body = payload(&resumed); + assert!(resume_body["exec_command"]["process_id"] + .as_str() + .unwrap() + .starts_with("proc-")); + + for _ in 0..50 { + if ws.join("held.txt").exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!(std::fs::read_to_string(ws.join("held.txt")).unwrap(), "x"); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn explicit_create_works_when_approvals_are_off() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("off")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let created = client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_CREATE).with_arguments(object!({ + "tool": "apply_patch", + "arguments": { + "workspace_id": "demo", + "patch": ADD_PATCH + } + })), + ) + .await + .expect("create"); + let approval_id = payload(&created)["approval_id"] + .as_str() + .unwrap() + .to_string(); + assert_eq!(payload(&created)["state"], "pending"); + assert!(!ws.join("created.txt").exists()); + + client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": approval_id, + "decision": "grant" + })), + ) + .await + .expect("grant"); + client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume"); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + + client.cancel().await.expect("cancel"); +} + +fn open_store(db: &std::path::Path) -> codespace_store::Store { + codespace_store::Store::open(db).expect("open store") +} + +fn inspect_approval(db: &std::path::Path, id: &str) -> (ApprovalState, String, Option) { + open_store(db) + .inspect_approval(&ApprovalId(id.to_string())) + .expect("inspect") +} + +fn force_approval_state(db: &std::path::Path, id: &str, state: ApprovalState, clear_result: bool) { + open_store(db) + .force_approval_state(&ApprovalId(id.to_string()), state, clear_result) + .expect("force state") +} + +async fn grant(client: &rmcp::service::RunningService, approval_id: &str) { + client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": approval_id, + "decision": "grant" + })), + ) + .await + .expect("grant"); +} + +#[tokio::test] +async fn same_apply_retry_reuses_active_hold() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let first = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "retry-hold" + })), + ) + .await; + let approval_id = error_body(&first)["approval_id"] + .as_str() + .unwrap() + .to_string(); + let retry = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "retry-hold" + })), + ) + .await; + let retry_body = error_body(&retry); + assert_eq!(retry_body["code"], "APPROVAL_REQUIRED"); + assert_eq!(retry_body["approval_id"], approval_id); + assert!(!ws.join("created.txt").exists()); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn same_exec_retry_does_not_double_spawn() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/sh", "-c", "printf x >> held.txt"] + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + let retry = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/sh", "-c", "printf x >> held.txt"] + })), + ) + .await; + assert_eq!(error_body(&retry)["approval_id"], approval_id); + + grant(&client, &approval_id).await; + client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume"); + for _ in 0..50 { + if ws.join("held.txt").exists() { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(20)).await; + } + assert_eq!(std::fs::read_to_string(ws.join("held.txt")).unwrap(), "x"); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn concurrent_resume_runs_the_patch_once() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "concurrent" + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &approval_id).await; + + let (first, second) = tokio::join!( + client.call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id.clone() })), + ), + client.call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ), + ); + let mut operation_ids = Vec::new(); + for result in [&first, &second] { + let applied = result.as_ref().ok().and_then(|ok| { + payload(ok) + .get("apply_patch") + .and_then(|patch| patch.get("operation_id")) + .and_then(|id| id.as_str()) + .map(str::to_string) + }); + if let Some(operation_id) = applied { + operation_ids.push(operation_id); + continue; + } + let body = error_body(result); + assert!( + matches!( + body["code"].as_str(), + Some("APPROVAL_CONFLICT" | "APPROVAL_AMBIGUOUS" | "WORKSPACE_BUSY") + ), + "{body}" + ); + } + operation_ids.sort(); + operation_ids.dedup(); + assert_eq!(operation_ids.len(), 1); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn restart_resuming_patch_without_ledger_runs_once() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "crash-before-begin" + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &approval_id).await; + client.cancel().await.expect("cancel"); + force_approval_state(&db, &approval_id, ApprovalState::Resuming, true); + assert!(!ws.join("created.txt").exists()); + + let client = spawn_client(&cfg, &db).await; + let resumed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume after restart"); + assert_eq!(payload(&resumed)["apply_patch"]["status"], "applied"); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn restart_resuming_patch_recovers_from_ledger() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "recover-ledger" + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &approval_id).await; + let resumed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("first resume"); + let operation_id = payload(&resumed)["apply_patch"]["operation_id"] + .as_str() + .unwrap() + .to_string(); + client.cancel().await.expect("cancel"); + force_approval_state(&db, &approval_id, ApprovalState::Resuming, true); + + let client = spawn_client(&cfg, &db).await; + let recovered = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("recover"); + assert_eq!( + payload(&recovered)["apply_patch"]["operation_id"], + operation_id + ); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn restart_resuming_exec_is_ambiguous_without_spawn() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_EXEC_COMMAND).with_arguments(object!({ + "workspace_id": "demo", + "command": ["/bin/sh", "-c", "printf x > held.txt"] + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &approval_id).await; + client.cancel().await.expect("cancel"); + force_approval_state(&db, &approval_id, ApprovalState::Resuming, true); + + let client = spawn_client(&cfg, &db).await; + let resumed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await; + assert_eq!(error_body(&resumed)["code"], "APPROVAL_AMBIGUOUS"); + assert!(!ws.join("held.txt").exists()); + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn restart_preserves_pending_granted_and_consumed() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let pending = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "pending-row" + })), + ) + .await; + let pending_id = error_body(&pending)["approval_id"] + .as_str() + .unwrap() + .to_string(); + + let granted = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: granted.txt\n+g\n*** End Patch\n", + "operation_key": "granted-row" + })), + ) + .await; + let granted_id = error_body(&granted)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &granted_id).await; + + let consumed = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: consumed.txt\n+c\n*** End Patch\n", + "operation_key": "consumed-row" + })), + ) + .await; + let consumed_id = error_body(&consumed)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &consumed_id).await; + client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": consumed_id })), + ) + .await + .expect("consume"); + client.cancel().await.expect("cancel"); + + let (pending_state, _, _) = inspect_approval(&db, &pending_id); + let (granted_state, _, _) = inspect_approval(&db, &granted_id); + let (consumed_state, consumed_params, consumed_result) = inspect_approval(&db, &consumed_id); + assert_eq!(pending_state, ApprovalState::Pending); + assert_eq!(granted_state, ApprovalState::Granted); + assert_eq!(consumed_state, ApprovalState::Consumed); + assert!(consumed_params.contains("\"scrubbed\":true")); + assert!(consumed_result.is_some()); + + let client = spawn_client(&cfg, &db).await; + let pending_retry = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH, + "operation_key": "pending-row" + })), + ) + .await; + assert_eq!(error_body(&pending_retry)["approval_id"], pending_id); + + let resumed_granted = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": granted_id })), + ) + .await + .expect("resume granted"); + assert_eq!( + payload(&resumed_granted)["apply_patch"]["status"], + "applied" + ); + assert_eq!( + std::fs::read_to_string(ws.join("granted.txt")).unwrap(), + "g\n" + ); + + let replayed = client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": consumed_id })), + ) + .await + .expect("replay consumed"); + assert_eq!(payload(&replayed)["apply_patch"]["status"], "applied"); + assert_eq!( + std::fs::read_to_string(ws.join("consumed.txt")).unwrap(), + "c\n" + ); + client.cancel().await.expect("cancel"); +} + +#[tokio::test] +async fn terminal_rows_scrub_params_json() { + let (root, cfg, _ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + + let denied = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH + })), + ) + .await; + let denied_id = error_body(&denied)["approval_id"] + .as_str() + .unwrap() + .to_string(); + client + .call_tool( + CallToolRequestParams::new(TOOL_APPROVAL_RESOLVE).with_arguments(object!({ + "approval_id": denied_id, + "decision": "deny" + })), + ) + .await + .expect("deny"); + + let consumed = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: other.txt\n+x\n*** End Patch\n" + })), + ) + .await; + let consumed_id = error_body(&consumed)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &consumed_id).await; + client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": consumed_id })), + ) + .await + .expect("resume"); + client.cancel().await.expect("cancel"); + + let (_, denied_params, _) = inspect_approval(&db, &denied_id); + let (_, consumed_params, _) = inspect_approval(&db, &consumed_id); + assert!( + denied_params.contains("\"scrubbed\":true"), + "{denied_params}" + ); + assert!(!denied_params.contains("Begin Patch"), "{denied_params}"); + assert!( + consumed_params.contains("\"scrubbed\":true"), + "{consumed_params}" + ); + assert!( + !consumed_params.contains("Begin Patch"), + "{consumed_params}" + ); +} + +#[tokio::test] +async fn same_mcp_client_can_grant_without_host_isolation() { + let (root, cfg, ws) = write_workspace("workspace-write", Some("confirm")); + let db = root.path().join("ops.sqlite"); + let client = spawn_client(&cfg, &db).await; + let held = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": ADD_PATCH + })), + ) + .await; + let approval_id = error_body(&held)["approval_id"] + .as_str() + .unwrap() + .to_string(); + grant(&client, &approval_id).await; + client + .call_tool( + CallToolRequestParams::new(TOOL_OPERATION_RESUME) + .with_arguments(object!({ "approval_id": approval_id })), + ) + .await + .expect("resume"); + assert_eq!( + std::fs::read_to_string(ws.join("created.txt")).unwrap(), + "hello\n" + ); + client.cancel().await.expect("cancel"); +} diff --git a/crates/server/tests/policy_contract.rs b/crates/server/tests/policy_contract.rs index f90cb23..9941985 100644 --- a/crates/server/tests/policy_contract.rs +++ b/crates/server/tests/policy_contract.rs @@ -1,4 +1,4 @@ -use codespace_domain::TOOL_WORKSPACE_INFO; +use codespace_domain::{TOOL_APPLY_PATCH, TOOL_WORKSPACE_INFO}; use rmcp::{ model::CallToolRequestParams, object, @@ -66,5 +66,28 @@ async fn unknown_workspace_is_rejected_known_is_selector() { assert_eq!(body["workspace_id_is_credential"], false); assert_eq!(body["profile"], "read-only"); + let apply = client + .call_tool( + CallToolRequestParams::new(TOOL_APPLY_PATCH).with_arguments(object!({ + "workspace_id": "demo", + "patch": "*** Begin Patch\n*** Add File: x.txt\n+x\n*** End Patch\n", + "approved": true + })), + ) + .await; + let apply_text = match &apply { + Ok(result) => result + .content + .iter() + .filter_map(|c| c.as_text().map(|t| t.text.clone())) + .collect::>() + .join(""), + Err(err) => err.to_string(), + }; + assert!( + apply_text.contains("UNAUTHORIZED"), + "approved=true must not unlock read-only apply_patch: {apply_text}" + ); + client.cancel().await.expect("cancel"); } diff --git a/crates/server/tests/process.rs b/crates/server/tests/process.rs index 98e5dee..ed4c311 100644 --- a/crates/server/tests/process.rs +++ b/crates/server/tests/process.rs @@ -642,7 +642,7 @@ async fn exec_command_schema_has_optional_tty_and_live_tools_unchanged() { names.sort(); let mut expected = LIVE_TOOLS.to_vec(); expected.sort(); - assert_eq!(names, expected, "LIVE_TOOLS must stay unchanged"); + assert_eq!(names, expected, "tools/list must match LIVE_TOOLS"); let exec = tools .iter() diff --git a/crates/store/src/approvals.rs b/crates/store/src/approvals.rs new file mode 100644 index 0000000..da6253a --- /dev/null +++ b/crates/store/src/approvals.rs @@ -0,0 +1,779 @@ +//! Confirmation holds. Separate from the patch operations ledger. + +use codespace_domain::{ + ApprovalDecision, ApprovalId, ApprovalResolveResult, ApprovalState, ApprovalTargetTool, + ErrorBody, ErrorCode, OperationResumeResult, +}; +use rusqlite::{params, OptionalExtension}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::{now_secs, Store}; + +#[derive(Debug, Clone)] +pub struct ApprovalRecord { + pub approval_id: ApprovalId, + pub workspace_id: String, + pub tool: ApprovalTargetTool, + pub fingerprint: String, + pub params_json: String, + pub state: ApprovalState, + pub resolved_at: Option, +} + +pub struct ResumeInflightGuard<'a> { + store: &'a Store, + id: String, +} + +impl Drop for ResumeInflightGuard<'_> { + fn drop(&mut self) { + self.store + .resume_inflight + .lock() + .expect("inflight mutex") + .remove(&self.id); + } +} + +impl std::fmt::Debug for ResumeInflightGuard<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("ResumeInflightGuard") + .field("id", &self.id) + .finish() + } +} + +#[derive(Debug)] +pub enum ResumeClaim<'a> { + ReplaySuccess(OperationResumeResult), + ReplayError(ErrorBody), + Execute(ApprovalRecord, ResumeInflightGuard<'a>), + Reconcile(ApprovalRecord, ResumeInflightGuard<'a>), +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(tag = "outcome", rename_all = "snake_case")] +enum PersistedResume { + Success(OperationResumeResult), + Failed(ErrorBody), +} + +struct Row { + approval_id: String, + workspace_id: String, + tool: String, + fingerprint: String, + params_json: String, + state: String, + resolved_at: Option, + result_json: Option, +} + +impl Row { + fn into_record(self) -> Result { + Ok(ApprovalRecord { + approval_id: ApprovalId(self.approval_id), + workspace_id: self.workspace_id, + tool: ApprovalTargetTool::parse(&self.tool).map_err(bad_row)?, + fingerprint: self.fingerprint, + params_json: self.params_json, + state: ApprovalState::parse(&self.state).map_err(bad_row)?, + resolved_at: self.resolved_at, + }) + } +} + +impl Store { + pub fn create_approval( + &self, + workspace_id: &str, + tool: ApprovalTargetTool, + fingerprint: &str, + params_json: serde_json::Value, + ) -> Result { + let json = serde_json::to_string(¶ms_json).map_err(ser_err)?; + let now = now_secs(); + let conn = self.conn.lock().expect("sqlite mutex"); + if let Some(existing) = load_active_by_fingerprint(&conn, workspace_id, fingerprint)? { + return existing.into_record(); + } + let approval_id = ApprovalId(format!("appr-{}", Uuid::new_v4())); + match conn.execute( + "INSERT INTO approvals + (approval_id, workspace_id, tool, fingerprint, params_json, state, created_at, resolved_at, result_json) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, NULL, NULL)", + params![ + approval_id.0, + workspace_id, + tool.as_str(), + fingerprint, + json, + ApprovalState::Pending.as_str(), + now, + ], + ) { + Ok(_) => Ok(ApprovalRecord { + approval_id, + workspace_id: workspace_id.to_string(), + tool, + fingerprint: fingerprint.to_string(), + params_json: json, + state: ApprovalState::Pending, + resolved_at: None, + }), + Err(err) if is_constraint(&err) => load_active_by_fingerprint( + &conn, + workspace_id, + fingerprint, + )? + .ok_or_else(|| { + ErrorBody::new( + ErrorCode::ApprovalConflict, + "active approval fingerprint conflict", + ) + }) + .and_then(Row::into_record), + Err(err) => Err(db_err(err)), + } + } + + pub fn resolve_approval( + &self, + approval_id: &ApprovalId, + decision: ApprovalDecision, + ) -> Result { + let conn = self.conn.lock().expect("sqlite mutex"); + let row = load(&conn, &approval_id.0)?.ok_or_else(|| not_found(approval_id))?; + let state = ApprovalState::parse(&row.state).map_err(bad_row)?; + if state != ApprovalState::Pending { + return Err(conflict("approval is no longer pending")); + } + let now = now_secs(); + let updated = match decision { + ApprovalDecision::Grant => conn + .execute( + "UPDATE approvals + SET state = ?1, resolved_at = ?2 + WHERE approval_id = ?3 AND state = 'pending'", + params![ApprovalState::Granted.as_str(), now, approval_id.0], + ) + .map_err(db_err)?, + ApprovalDecision::Deny => conn + .execute( + "UPDATE approvals + SET state = ?1, resolved_at = ?2, params_json = ?3 + WHERE approval_id = ?4 AND state = 'pending'", + params![ + ApprovalState::Denied.as_str(), + now, + scrubbed_params(&row.tool, &row.workspace_id, &row.fingerprint), + approval_id.0 + ], + ) + .map_err(db_err)?, + }; + if updated != 1 { + return Err(conflict("approval is no longer pending")); + } + Ok(ApprovalResolveResult { + approval_id: approval_id.clone(), + state: match decision { + ApprovalDecision::Grant => ApprovalState::Granted, + ApprovalDecision::Deny => ApprovalState::Denied, + }, + }) + } + + pub fn claim_resume(&self, approval_id: &ApprovalId) -> Result, ErrorBody> { + loop { + let conn = self.conn.lock().expect("sqlite mutex"); + let row = load(&conn, &approval_id.0)?.ok_or_else(|| not_found(approval_id))?; + match row.state.as_str() { + "consumed" => return replay_consumed(approval_id, row.result_json.as_deref()), + "pending" => return Err(conflict("approval is still pending")), + "denied" => return Err(conflict("approval was denied")), + "granted" | "resuming" => { + drop(conn); + let guard = self.mark_inflight(&approval_id.0)?; + let conn = self.conn.lock().expect("sqlite mutex"); + let row = match load(&conn, &approval_id.0)? { + Some(row) => row, + None => { + drop(conn); + drop(guard); + return Err(not_found(approval_id)); + } + }; + match row.state.as_str() { + "granted" => { + let updated = conn + .execute( + "UPDATE approvals + SET state = 'resuming' + WHERE approval_id = ?1 AND state = 'granted'", + params![approval_id.0], + ) + .map_err(db_err)?; + if updated != 1 { + drop(conn); + drop(guard); + continue; + } + return Ok(ResumeClaim::Execute(row.into_record()?, guard)); + } + "resuming" => { + return Ok(ResumeClaim::Reconcile(row.into_record()?, guard)); + } + "consumed" | "pending" | "denied" => { + drop(conn); + drop(guard); + continue; + } + other => { + drop(conn); + drop(guard); + return Err(conflict(format!( + "approval is in an unknown state `{other}`" + ))); + } + } + } + other => { + return Err(conflict(format!( + "approval is in an unknown state `{other}`" + ))) + } + } + } + } + + pub fn finish_resume( + &self, + approval_id: &ApprovalId, + result: Result<&OperationResumeResult, &ErrorBody>, + ) -> Result<(), ErrorBody> { + if self.take_fail_next_finish() { + return Err(ambiguous( + approval_id, + "resume result could not be persisted", + result.ok().and_then(|ok| { + ok.apply_patch + .as_ref() + .map(|patch| patch.operation_id.0.clone()) + }), + )); + } + let persisted = match result { + Ok(success) => PersistedResume::Success(success.clone()), + Err(err) => PersistedResume::Failed(err.clone()), + }; + let json = serde_json::to_string(&persisted).map_err(ser_err)?; + let conn = self.conn.lock().expect("sqlite mutex"); + let row = load(&conn, &approval_id.0)?.ok_or_else(|| not_found(approval_id))?; + let scrubbed = scrubbed_params(&row.tool, &row.workspace_id, &row.fingerprint); + let updated = conn + .execute( + "UPDATE approvals + SET state = 'consumed', result_json = ?1, params_json = ?2 + WHERE approval_id = ?3 AND state = 'resuming'", + params![json, scrubbed, approval_id.0], + ) + .map_err(db_err)?; + if updated != 1 { + return Err(ambiguous( + approval_id, + "resume result could not be persisted", + None, + )); + } + Ok(()) + } + + pub fn fail_next_finish(&self) { + *self.fail_next_finish.lock().expect("fail flag") = true; + } + + pub fn force_approval_state( + &self, + approval_id: &ApprovalId, + state: ApprovalState, + clear_result: bool, + ) -> Result<(), ErrorBody> { + let conn = self.conn.lock().expect("sqlite mutex"); + let updated = if clear_result { + conn.execute( + "UPDATE approvals SET state = ?1, result_json = NULL WHERE approval_id = ?2", + params![state.as_str(), approval_id.0], + ) + .map_err(db_err)? + } else { + conn.execute( + "UPDATE approvals SET state = ?1 WHERE approval_id = ?2", + params![state.as_str(), approval_id.0], + ) + .map_err(db_err)? + }; + if updated != 1 { + return Err(not_found(approval_id)); + } + Ok(()) + } + + pub fn inspect_approval( + &self, + approval_id: &ApprovalId, + ) -> Result<(ApprovalState, String, Option), ErrorBody> { + let conn = self.conn.lock().expect("sqlite mutex"); + let row = load(&conn, &approval_id.0)?.ok_or_else(|| not_found(approval_id))?; + Ok(( + ApprovalState::parse(&row.state).map_err(bad_row)?, + row.params_json, + row.result_json, + )) + } + + fn mark_inflight(&self, id: &str) -> Result, ErrorBody> { + let mut set = self.resume_inflight.lock().expect("inflight mutex"); + if !set.insert(id.to_string()) { + return Err(conflict("resume already in progress")); + } + Ok(ResumeInflightGuard { + store: self, + id: id.to_string(), + }) + } + + fn take_fail_next_finish(&self) -> bool { + let mut flag = self.fail_next_finish.lock().expect("fail flag"); + let set = *flag; + *flag = false; + set + } +} + +fn load(conn: &rusqlite::Connection, approval_id: &str) -> Result, ErrorBody> { + conn.query_row( + "SELECT approval_id, workspace_id, tool, fingerprint, params_json, state, resolved_at, result_json + FROM approvals WHERE approval_id = ?1", + params![approval_id], + map_row, + ) + .optional() + .map_err(db_err) +} + +fn load_active_by_fingerprint( + conn: &rusqlite::Connection, + workspace_id: &str, + fingerprint: &str, +) -> Result, ErrorBody> { + conn.query_row( + "SELECT approval_id, workspace_id, tool, fingerprint, params_json, state, resolved_at, result_json + FROM approvals + WHERE workspace_id = ?1 AND fingerprint = ?2 + AND state IN ('pending','granted','resuming') + LIMIT 1", + params![workspace_id, fingerprint], + map_row, + ) + .optional() + .map_err(db_err) +} + +fn map_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(Row { + approval_id: row.get(0)?, + workspace_id: row.get(1)?, + tool: row.get(2)?, + fingerprint: row.get(3)?, + params_json: row.get(4)?, + state: row.get(5)?, + resolved_at: row.get(6)?, + result_json: row.get(7)?, + }) +} + +fn scrubbed_params(tool: &str, workspace_id: &str, fingerprint: &str) -> String { + serde_json::json!({ + "scrubbed": true, + "tool": tool, + "workspace_id": workspace_id, + "fingerprint": fingerprint, + }) + .to_string() +} + +fn not_found(id: &ApprovalId) -> ErrorBody { + ErrorBody::new( + ErrorCode::ApprovalNotFound, + format!("unknown approval_id `{}`", id.0), + ) +} + +fn conflict(message: impl Into) -> ErrorBody { + ErrorBody::new(ErrorCode::ApprovalConflict, message) +} + +fn replay_consumed<'a>( + approval_id: &ApprovalId, + result_json: Option<&str>, +) -> Result, ErrorBody> { + match result_json { + Some(raw) => match serde_json::from_str::(raw) { + Ok(PersistedResume::Success(result)) => Ok(ResumeClaim::ReplaySuccess(result)), + Ok(PersistedResume::Failed(err)) => Ok(ResumeClaim::ReplayError(err)), + Err(_) => Err(ambiguous( + approval_id, + "consumed approval has an unreadable result", + None, + )), + }, + None => Err(ambiguous( + approval_id, + "consumed approval is missing a terminal result", + None, + )), + } +} + +fn ambiguous( + id: &ApprovalId, + message: impl Into, + operation_id: Option, +) -> ErrorBody { + let mut err = + ErrorBody::new(ErrorCode::ApprovalAmbiguous, message).with_approval_id(id.0.as_str()); + if let Some(operation_id) = operation_id { + err = err.with_operation_id(operation_id); + } + err +} + +fn db_err(err: rusqlite::Error) -> ErrorBody { + ErrorBody::new(ErrorCode::ApprovalNotFound, format!("store: {err}")) +} + +fn ser_err(err: serde_json::Error) -> ErrorBody { + ErrorBody::new(ErrorCode::InvalidPatch, err.to_string()) +} + +fn bad_row(message: String) -> ErrorBody { + ErrorBody::new(ErrorCode::ApprovalConflict, message) +} + +fn is_constraint(err: &rusqlite::Error) -> bool { + matches!( + err, + rusqlite::Error::SqliteFailure(info, _) + if info.code == rusqlite::ErrorCode::ConstraintViolation + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use codespace_domain::{ApplyPatchParams, OperationId, PatchStatus, WorkspaceId}; + use serde_json::json; + use std::collections::BTreeMap; + + fn patch_params() -> ApplyPatchParams { + ApplyPatchParams { + workspace_id: WorkspaceId("demo".into()), + patch: "x".into(), + expected_versions: BTreeMap::new(), + operation_key: None, + check_only: false, + work_id: None, + } + } + + #[test] + fn grant_then_consume_replays_stored_result() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(¶ms), + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + assert_eq!(created.state, ApprovalState::Pending); + assert!(created.approval_id.0.starts_with("appr-")); + + let granted = store + .resolve_approval(&created.approval_id, ApprovalDecision::Grant) + .unwrap(); + assert_eq!(granted.state, ApprovalState::Granted); + + let result = OperationResumeResult { + approval_id: created.approval_id.clone(), + state: ApprovalState::Consumed, + apply_patch: Some(codespace_domain::ApplyPatchResult::new( + PatchStatus::Applied, + OperationId("op-1".into()), + )), + exec_command: None, + }; + { + let ResumeClaim::Execute(record, _guard) = + store.claim_resume(&created.approval_id).unwrap() + else { + panic!("expected execute"); + }; + assert_eq!(record.tool, ApprovalTargetTool::ApplyPatch); + assert_eq!( + store.claim_resume(&created.approval_id).unwrap_err().code, + ErrorCode::ApprovalConflict + ); + store + .finish_resume(&created.approval_id, Ok(&result)) + .unwrap(); + } + match store.claim_resume(&created.approval_id).unwrap() { + ResumeClaim::ReplaySuccess(replayed) => { + assert_eq!(replayed.apply_patch.unwrap().status, PatchStatus::Applied); + } + other => panic!("expected replay, got {other:?}"), + } + let (_, params_json, _) = store.inspect_approval(&created.approval_id).unwrap(); + assert!(params_json.contains("\"scrubbed\":true")); + } + + #[test] + fn deny_scrubs_and_cannot_resume() { + let store = Store::memory().unwrap(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ExecCommand, + "sha256:exec", + json!({"workspace_id":"demo","command":["/bin/echo"]}), + ) + .unwrap(); + store + .resolve_approval(&created.approval_id, ApprovalDecision::Deny) + .unwrap(); + let (_, params_json, _) = store.inspect_approval(&created.approval_id).unwrap(); + assert!(params_json.contains("\"scrubbed\":true")); + assert_eq!( + store.claim_resume(&created.approval_id).unwrap_err().code, + ErrorCode::ApprovalConflict + ); + } + + #[test] + fn create_reuses_active_fingerprint() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let fp = Store::fingerprint(¶ms); + let first = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &fp, + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + let second = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &fp, + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + assert_eq!(first.approval_id, second.approval_id); + } + + #[test] + fn consumed_without_result_is_ambiguous() { + let store = Store::memory().unwrap(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + "sha256:x", + json!({"workspace_id":"demo","patch":"x"}), + ) + .unwrap(); + store + .force_approval_state(&created.approval_id, ApprovalState::Consumed, true) + .unwrap(); + assert_eq!( + store.claim_resume(&created.approval_id).unwrap_err().code, + ErrorCode::ApprovalAmbiguous + ); + } + + #[test] + fn persist_failure_leaves_resuming() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(¶ms), + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + store + .resolve_approval(&created.approval_id, ApprovalDecision::Grant) + .unwrap(); + let result = OperationResumeResult { + approval_id: created.approval_id.clone(), + state: ApprovalState::Consumed, + apply_patch: Some(codespace_domain::ApplyPatchResult::new( + PatchStatus::Applied, + OperationId("op-1".into()), + )), + exec_command: None, + }; + let _guard = match store.claim_resume(&created.approval_id).unwrap() { + ResumeClaim::Execute(_, guard) => guard, + other => panic!("expected execute, got {other:?}"), + }; + store.fail_next_finish(); + assert_eq!( + store + .finish_resume(&created.approval_id, Ok(&result)) + .unwrap_err() + .code, + ErrorCode::ApprovalAmbiguous + ); + let (state, _, result_json) = store.inspect_approval(&created.approval_id).unwrap(); + assert_eq!(state, ApprovalState::Resuming); + assert!(result_json.is_none()); + } + + #[test] + fn restart_resuming_is_reconcile() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(¶ms), + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + store + .resolve_approval(&created.approval_id, ApprovalDecision::Grant) + .unwrap(); + drop(match store.claim_resume(&created.approval_id).unwrap() { + ResumeClaim::Execute(_, guard) => guard, + other => panic!("expected execute, got {other:?}"), + }); + match store.claim_resume(&created.approval_id).unwrap() { + ResumeClaim::Reconcile(record, _guard) => { + assert_eq!(record.state, ApprovalState::Resuming); + } + other => panic!("expected reconcile, got {other:?}"), + }; + } + + #[test] + fn consumed_fingerprint_can_open_a_new_hold() { + let store = Store::memory().unwrap(); + let params = patch_params(); + let fp = Store::fingerprint(¶ms); + let snapshot = serde_json::to_value(¶ms).unwrap(); + let first = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &fp, + snapshot.clone(), + ) + .unwrap(); + store + .resolve_approval(&first.approval_id, ApprovalDecision::Grant) + .unwrap(); + let result = OperationResumeResult { + approval_id: first.approval_id.clone(), + state: ApprovalState::Consumed, + apply_patch: Some(codespace_domain::ApplyPatchResult::new( + PatchStatus::Applied, + OperationId("op-1".into()), + )), + exec_command: None, + }; + { + let ResumeClaim::Execute(_, _guard) = store.claim_resume(&first.approval_id).unwrap() + else { + panic!("expected execute"); + }; + store + .finish_resume(&first.approval_id, Ok(&result)) + .unwrap(); + } + let second = store + .create_approval("demo", ApprovalTargetTool::ApplyPatch, &fp, snapshot) + .unwrap(); + assert_ne!(first.approval_id, second.approval_id); + assert_eq!(second.state, ApprovalState::Pending); + } + + #[test] + fn concurrent_claim_executes_once() { + let store = std::sync::Arc::new(Store::memory().unwrap()); + let params = patch_params(); + let created = store + .create_approval( + "demo", + ApprovalTargetTool::ApplyPatch, + &Store::fingerprint(¶ms), + serde_json::to_value(¶ms).unwrap(), + ) + .unwrap(); + store + .resolve_approval(&created.approval_id, ApprovalDecision::Grant) + .unwrap(); + let start = std::sync::Arc::new(std::sync::Barrier::new(2)); + let seen = std::sync::Arc::new(std::sync::Barrier::new(2)); + let outcomes = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let spawn = + |store: std::sync::Arc, + id: ApprovalId, + start: std::sync::Arc, + seen: std::sync::Arc, + outcomes: std::sync::Arc>>| { + std::thread::spawn(move || { + start.wait(); + let claim = store.claim_resume(&id); + let label = match &claim { + Ok(ResumeClaim::Execute(_, _)) => "execute", + Ok(ResumeClaim::Reconcile(_, _)) => "reconcile", + Err(err) if err.code == ErrorCode::ApprovalConflict => "conflict", + other => panic!("unexpected claim: {other:?}"), + }; + outcomes.lock().expect("outcomes").push(label); + seen.wait(); + drop(claim); + }) + }; + let first = spawn( + store.clone(), + created.approval_id.clone(), + start.clone(), + seen.clone(), + outcomes.clone(), + ); + let second = spawn(store, created.approval_id, start, seen, outcomes.clone()); + first.join().unwrap(); + second.join().unwrap(); + let labels = outcomes.lock().expect("outcomes").clone(); + assert_eq!( + labels.iter().filter(|label| **label == "execute").count(), + 1 + ); + assert_eq!( + labels.iter().filter(|label| **label == "conflict").count(), + 1 + ); + } +} diff --git a/crates/store/src/lib.rs b/crates/store/src/lib.rs index f36b03e..96f8194 100644 --- a/crates/store/src/lib.rs +++ b/crates/store/src/lib.rs @@ -1,18 +1,24 @@ -//! Single-instance SQLite operations and in-process workspace write locks. +//! Single-instance SQLite operations, confirmation holds, and in-process workspace write locks. //! HTTP/JSON-RPC request ids are never stored as [`OperationId`] values. +mod approvals; +mod coord; mod resource; +use std::collections::HashSet; use std::path::Path; use std::sync::Mutex; use codespace_domain::{ - ApplyPatchParams, ApplyPatchResult, ErrorBody, ErrorCode, OperationEvent, OperationEventName, - OperationId, OperationKey, OperationKind, OperationStatusResult, PatchStatus, WorkspaceId, + ApplyPatchParams, ApplyPatchResult, ErrorBody, ErrorCode, ExecCommandParams, OperationEvent, + OperationEventName, OperationId, OperationKey, OperationKind, OperationStatusResult, + PatchStatus, WorkspaceId, }; use rusqlite::{params, Connection, OptionalExtension}; use sha2::{Digest, Sha256}; +pub use approvals::{ApprovalRecord, ResumeClaim}; +pub use coord::CreateIntent; pub use resource::{LockMode, Resource, ResourceGuard}; #[derive(Debug, Clone)] @@ -36,6 +42,8 @@ pub enum Begin { pub struct Store { conn: Mutex, locks: Mutex, + resume_inflight: Mutex>, + fail_next_finish: Mutex, } pub struct WriteGuard<'a> { @@ -109,23 +117,45 @@ impl Store { WHERE work_id IS NULL;", ) .map_err(|e| e.to_string())?; + conn.execute_batch( + "CREATE TABLE IF NOT EXISTS approvals ( + approval_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + tool TEXT NOT NULL, + fingerprint TEXT NOT NULL, + params_json TEXT NOT NULL, + state TEXT NOT NULL, + created_at INTEGER NOT NULL, + resolved_at INTEGER, + result_json TEXT + );", + ) + .map_err(|e| e.to_string())?; migrate_operations(&conn)?; + migrate_approvals(&conn)?; Ok(Self { conn: Mutex::new(conn), locks: Mutex::new(resource::ResourceSerializer::default()), + resume_inflight: Mutex::new(HashSet::new()), + fail_next_finish: Mutex::new(false), }) } pub fn fingerprint(params: &ApplyPatchParams) -> String { - let value = serde_json::json!({ + hash_canonical(&serde_json::json!({ "workspace_id": params.workspace_id.0, "patch": params.patch, "expected_versions": params.expected_versions, "check_only": params.check_only, - }); - let mut hasher = Sha256::new(); - hasher.update(value.to_string().as_bytes()); - format!("sha256:{}", hex::encode(hasher.finalize())) + })) + } + + pub fn exec_fingerprint(params: &ExecCommandParams) -> String { + hash_canonical(&serde_json::json!({ + "workspace_id": params.workspace_id.0, + "command": params.command, + "tty": params.tty, + })) } pub fn try_acquire_write<'a>( @@ -315,6 +345,26 @@ impl Store { )), } } + + pub fn find_operation_by_fingerprint( + &self, + workspace_id: &str, + fingerprint: &str, + created_not_before: Option, + ) -> Result, ErrorBody> { + let conn = self.conn.lock().expect("sqlite mutex"); + let sql = format!( + "{OPERATION_SELECT} WHERE workspace_id = ?1 AND fingerprint = ?2 AND (?3 IS NULL OR created_at >= ?3) ORDER BY created_at DESC LIMIT 1" + ); + conn.query_row( + &sql, + params![workspace_id, fingerprint, created_not_before], + map_operation_row, + ) + .optional() + .map_err(sql_err) + .map(|row| row.map(|row| row.into_stored(false))) + } } fn to_status(stored: StoredOperation) -> OperationStatusResult { @@ -405,7 +455,7 @@ fn load_by_id(conn: &Connection, id: &str) -> Result, ErrorBody> { } fn migrate_operations(conn: &Connection) -> Result<(), String> { - let columns = operation_columns(conn)?; + let columns = table_columns(conn, "operations")?; if !columns.iter().any(|name| name == "finished_at") { conn.execute("ALTER TABLE operations ADD COLUMN finished_at INTEGER", []) .map_err(|err| err.to_string())?; @@ -417,9 +467,27 @@ fn migrate_operations(conn: &Connection) -> Result<(), String> { Ok(()) } -fn operation_columns(conn: &Connection) -> Result, String> { +fn migrate_approvals(conn: &Connection) -> Result<(), String> { + let columns = table_columns(conn, "approvals")?; + if !columns.iter().any(|name| name == "fingerprint") { + conn.execute( + "ALTER TABLE approvals ADD COLUMN fingerprint TEXT NOT NULL DEFAULT ''", + [], + ) + .map_err(|err| err.to_string())?; + } + conn.execute_batch( + "CREATE UNIQUE INDEX IF NOT EXISTS idx_approvals_active_fp + ON approvals(workspace_id, fingerprint) + WHERE state IN ('pending','granted','resuming') AND length(fingerprint) > 0;", + ) + .map_err(|err| err.to_string())?; + Ok(()) +} + +fn table_columns(conn: &Connection, table: &str) -> Result, String> { let mut stmt = conn - .prepare("PRAGMA table_info(operations)") + .prepare(&format!("PRAGMA table_info({table})")) .map_err(|err| err.to_string())?; let columns = stmt .query_map([], |row| row.get::<_, String>(1)) @@ -429,6 +497,12 @@ fn operation_columns(conn: &Connection) -> Result, String> { Ok(columns) } +fn hash_canonical(value: &serde_json::Value) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.to_string().as_bytes()); + format!("sha256:{}", hex::encode(hasher.finalize())) +} + fn status_str(status: PatchStatus) -> &'static str { match status { PatchStatus::Applied => "applied", @@ -455,10 +529,6 @@ pub(crate) fn now_secs() -> i64 { .unwrap_or(0) } -mod coord; - -pub use coord::CreateIntent; - #[cfg(test)] mod tests { use super::*; diff --git a/docs/agent-integration.md b/docs/agent-integration.md index bd763a7..7e819e8 100644 --- a/docs/agent-integration.md +++ b/docs/agent-integration.md @@ -19,7 +19,7 @@ Examples below are `tools/call` parameter objects, not complete JSON-RPC message } ``` -Inspect `execution.files.*.available`, `execution.process.available`, `execution.isolation.command_sandbox`, and `execution.network`. The tool list says what exists; workspace information says what the registered environment permits and supports. Availability does not reserve the workspace. Read-only workspaces cannot run commands. +Inspect `execution.files.*.available`, `execution.process.available`, `execution.isolation.command_sandbox`, `execution.network`, and `execution.approvals`. The tool list says what exists; workspace information says what the registered environment permits and supports. Availability does not reserve the workspace. Read-only workspaces cannot run commands. `approvals` is an operator setting, not a client grant. ## Read and patch a file @@ -130,10 +130,38 @@ Interactive input and cancellation use the same handle: A live command occupies the workspace. Wait for it to end or terminate it before applying a patch or starting another command. Reads and searches remain available. Long-lived development servers therefore require a workflow that stops them before edits. +## Confirm a held mutation + +Default workspaces run allowed patches and commands immediately. If the operator set `approvals` to `confirm`, those tools return `APPROVAL_REQUIRED` and an `approval_id` instead of writing or spawning. This is a workflow pause, not a privilege grant, isolation boundary, or a way to raise `read-only` to write or exec. Extra arguments such as `approved: true` or `network: true` do not grant rights. The same MCP caller can grant the hold. + +```json +{ + "name": "approval_resolve", + "arguments": { + "approval_id": "APPROVAL_ID_FROM_HOLD", + "decision": "grant" + } +} +``` + +```json +{ + "name": "operation_resume", + "arguments": { + "approval_id": "APPROVAL_ID_FROM_HOLD" + } +} +``` + +`approval_resolve` does not change the permission profile. `operation_resume` re-checks policy, then runs the original apply or exec path once. `consumed` is stored only with a terminal result. Repeating resume returns that stored result, recovers a recorded patch from the operations ledger, or returns `APPROVAL_AMBIGUOUS`. An interrupted exec resume is not respawned. Deny is terminal. The same three tools exist when approvals are `off`; only an explicit `approval_create` opens a hold in that mode. v1 does not distinguish host from model: the same MCP caller can grant. + ## Retry and recover deliberately | Situation | Agent action | | --- | --- | +| `APPROVAL_REQUIRED` | Policy allowed the mutation; grant with `approval_resolve` then `operation_resume`. Do not treat this as a grant of extra rights | +| `APPROVAL_CONFLICT` | The hold is still pending, was denied, or a resume is already in progress | +| `APPROVAL_AMBIGUOUS` | Resume was interrupted and the terminal result is not known; do not respawn exec. A patch may still be recoverable with `operation_status` | | Patch response lost | Query `operation_status` using the original key, or the operation ID if known | | `VERSION_CONFLICT` | Read current content and produce a new patch; do not force the old one | | `OPERATION_KEY_CONFLICT` | The key belongs to different arguments; inspect the earlier request | diff --git a/docs/architecture.md b/docs/architecture.md index cf79ef3..15c443c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,11 +33,11 @@ The UDS worker and default runner both execute on the same host. A Linux command | `server` | MCP transport, HTTP authentication/inbox, request validation and orchestration | | `domain` | CodeSpace tool parameters, results, IDs, error and execution types | | `policy` | Registered roots, environments, profiles, and network policy | -| `store` | SQLite patch operations, logical works, user instructions; in-memory occupancy | +| `store` | SQLite patch operations, confirmation holds, logical works, user instructions; in-memory occupancy | | `runner` | Execution DTOs, file scope, patch transaction, process supervision, UDS client/server protocol | | Isolated adapters | Codex patch, PTY, filesystem, worker hardening/socket, Linux sandbox mechanisms | -Patch operations and works/intents survive restart only with a configured SQLite file. Process handles and occupancy leases are memory-only. `operation_status` does not track exec requests. The transport request ID, patch operation ID, process ID, work ID, and instruction ID serve different purposes. +Patch operations, confirmation holds, and works/intents survive restart only with a configured SQLite file. Process handles and occupancy leases are memory-only. `operation_status` does not track exec requests. The transport request ID, patch operation ID, process ID, work ID, instruction ID, and approval ID serve different purposes. @@ -56,7 +56,9 @@ MCP request completion does not end a managed process. Clients continue with its ## Extension boundaries -The core does not import Codex types directly. Adapters may depend on a broader Codex execution graph; this does not make the gateway a Codex agent. Operator configuration selects environments, while MCP clients select only registered workspaces. Container execution, remote runners, approval-resume tools, and a resource scheduler are not implemented. +The core does not import Codex types directly. Adapters may depend on a broader Codex execution graph; this does not make the gateway a Codex agent. Operator configuration selects environments, while MCP clients select only registered workspaces. Container execution, remote runners, and a resource scheduler are not implemented. + +Confirmation-hold tools (`approval_create`, `approval_resolve`, `operation_resume`) are implemented. They pause a mutation the profile already allows until the hold is granted. This is not a security boundary: they do not raise `read-only` to write/exec, honor `ClientClaims.approved`, or change the permission profile. The same MCP caller can grant. Resume re-checks policy. v1 does not separate host and model callers. Keep a patch transaction as one Runner call when adding transports. Keep permission decisions in the gateway rather than importing Codex user/session permissions as authority. [Execution contracts](execution-substrate.md) describe current invariants; [Codex reuse](codex-reuse.md) lists connected adapters. diff --git a/docs/error-codes.md b/docs/error-codes.md index 3c3e367..c7e087b 100644 --- a/docs/error-codes.md +++ b/docs/error-codes.md @@ -16,7 +16,7 @@ Authentication rejection before a handler creates no operation. A disconnect or ## Tool error codes -Errors use uppercase identifiers and a message. An `operation_id` may be present if a patch record was created before the failure. Policy, occupancy, and key-conflict failures before that point do not have one. +Errors use uppercase identifiers and a message. An `operation_id` may be present if a patch record was created before the failure. An `approval_id` is present on confirmation-hold errors. Policy, occupancy, and key-conflict failures before a patch record do not have an operation id. | Code | Meaning | | --- | --- | @@ -47,6 +47,10 @@ Errors use uppercase identifiers and a message. An `operation_id` may be present | `INTENT_NOT_EDITABLE` | Instruction state does not permit editing | | `INTENT_REVISION_CONFLICT` | Instruction revision changed | | `QUEUE_NOT_EMPTY` | Reserved code; work_finish currently returns closed:false | +| `APPROVAL_REQUIRED` | Policy allowed the mutation; confirmation is required before execution. Includes `approval_id`. Not a privilege grant | +| `APPROVAL_NOT_FOUND` | Unknown confirmation-hold id | +| `APPROVAL_CONFLICT` | Hold is still pending, already decided, or a resume is already in progress | +| `APPROVAL_AMBIGUOUS` | Resume was interrupted and the terminal result is not on disk. Includes `approval_id`; patch cases may also include `operation_id` | ## Dispatch and completion diff --git a/docs/execution-substrate.md b/docs/execution-substrate.md index a9fb8cf..a76ccb0 100644 --- a/docs/execution-substrate.md +++ b/docs/execution-substrate.md @@ -26,6 +26,7 @@ The operator registry maps `read-only` and `workspace-write` to effective permis | Permission profile | Gateway-owned meaning of allowed file and process actions | | Operation | Persisted patch ledger with `operation_id`, optional idempotency key, `files`/`changes` hashes, and minted/finished events. Look up with `operation_status`. Does not track exec | | Process | Server-issued handle for a command; memory-only | +| Confirmation hold | Operator `approvals` setting; row in the `approvals` table (`pending`/`granted`/`resuming` until a terminal result); not a patch operation, privilege grant, or isolation boundary | | Work | Logical job and user-instruction queue; separate from a transport session | `environment_id` is not an MCP tool argument. A network proxy URL or Codex user configuration supplied by the model does not become execution authority. @@ -49,9 +50,15 @@ UDS transport and Linux sandbox preparation have distinct protocols and failure +## Confirmation holds + +`approval_create`, `approval_resolve`, and `operation_resume` are callable. They hold a mutation the workspace profile already allows until the hold is granted. This is not a security boundary: they do not escalate permissions, apply `{ "network": true }` or `ClientClaims.approved`, or write V4A snapshots into the patch operations ledger. The same MCP caller can grant. + +When the operator sets workspace `approvals` to `confirm`, a policy-allowed `apply_patch` or `exec_command` returns `APPROVAL_REQUIRED` with an `approval_id` before `begin()` or spawn. Retrying the same logical request reuses that active hold. `off` (the default) still runs those tools immediately; the three tools remain listed so an explicit `approval_create` can open a hold. Grant does not change the profile. Resume claims `granted` into `resuming`, re-checks `allow()`, then runs the existing apply or exec inner path. `consumed` is recorded only together with the terminal result. A later resume returns that result, recovers a patch from the operations ledger, or returns `APPROVAL_AMBIGUOUS`. Interrupted exec is not respawned. A denied policy stays `UNAUTHORIZED`. Exec remains on `process_id`. v1 does not authenticate host versus model. The server guarantees the policy re-check on resume plus that durability contract. + ## What remains unimplemented -Process exit codes and explicit output-loss metadata are not exposed to MCP. PTY resize, file range/pagination arguments, durable process recovery, container/remote dispatch, approval-resume tools, and a resource queue scheduler remain absent. Richer internal types and negotiated protocol flags do not imply those features are callable. +Process exit codes and explicit output-loss metadata are not exposed to MCP. PTY resize, file range/pagination arguments, durable process recovery, container/remote dispatch, and a resource queue scheduler remain absent. Richer internal types and negotiated protocol flags do not imply those features are callable. ## Maintaining the boundary diff --git a/docs/ko/agent-integration.md b/docs/ko/agent-integration.md index 9f41aa6..ac182fa 100644 --- a/docs/ko/agent-integration.md +++ b/docs/ko/agent-integration.md @@ -19,7 +19,7 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 } ``` -`execution.files.*.available`, `execution.process.available`, `execution.isolation.command_sandbox`, `execution.network`를 확인합니다. 도구 목록은 제공되는 기능을, 작업 공간 정보는 해당 환경의 권한과 지원 여부를 나타냅니다. 사용 가능 표시는 작업 공간 예약을 뜻하지 않습니다. 읽기 전용 작업 공간에서는 명령을 실행할 수 없습니다. +`execution.files.*.available`, `execution.process.available`, `execution.isolation.command_sandbox`, `execution.network`, `execution.approvals`를 확인합니다. 도구 목록은 제공되는 기능을, 작업 공간 정보는 해당 환경의 권한과 지원 여부를 나타냅니다. 사용 가능 표시는 작업 공간 예약을 뜻하지 않습니다. 읽기 전용 작업 공간에서는 명령을 실행할 수 없습니다. `approvals`는 운영자 설정이며 클라이언트가 권한을 올리는 인자가 아닙니다. ## 파일 읽기와 패치 @@ -130,10 +130,38 @@ MCP 클라이언트 SDK로 stdio 또는 Streamable HTTP를 초기화하고, 초 실행 중인 명령은 작업 공간을 점유합니다. 패치를 적용하거나 다른 명령을 시작하려면 기존 명령이 끝날 때까지 기다리거나 종료하세요. 읽기와 검색은 계속 가능합니다. 개발 서버를 오래 실행하는 작업 흐름이라면 수정 전에 서버를 멈추는 절차가 필요합니다. +## 보류된 변경 확인 + +기본 작업 공간에서는 허용된 패치와 명령이 바로 실행됩니다. 운영자가 `approvals`를 `confirm`으로 두면 해당 도구는 디스크에 쓰거나 프로세스를 만들지 않고 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 워크플로 일시정지이며 권한 부여나 격리 경계가 아니고, `read-only`를 쓰기·실행으로 올리는 방법도 아닙니다. `approved: true`나 `network: true` 같은 추가 인자도 권한을 주지 않습니다. 같은 MCP 호출자가 홀드를 grant할 수 있습니다. + +```json +{ + "name": "approval_resolve", + "arguments": { + "approval_id": "APPROVAL_ID_FROM_HOLD", + "decision": "grant" + } +} +``` + +```json +{ + "name": "operation_resume", + "arguments": { + "approval_id": "APPROVAL_ID_FROM_HOLD" + } +} +``` + +`approval_resolve`는 권한 프로필을 바꾸지 않습니다. `operation_resume`은 정책을 다시 검사한 뒤 원래 패치·실행 경로를 한 번 돌립니다. `consumed`는 단말 결과가 저장된 뒤에만 기록됩니다. 같은 홀드를 다시 재개하면 저장한 결과, 패치 원장 복구, 또는 `APPROVAL_AMBIGUOUS`가 반환됩니다. 중단된 exec 재개는 다시 spawn하지 않습니다. 거절은 단말입니다. `approvals`가 `off`여도 세 도구는 목록에 있으며, 그때는 명시적 `approval_create`만 홀드를 만듭니다. v1은 호스트와 모델을 구분하지 않으므로 같은 MCP 호출자가 grant할 수 있습니다. + ## 재시도와 복구 | 상황 | 에이전트가 해야 할 일 | | --- | --- | +| `APPROVAL_REQUIRED` | 정책은 허용했으나 실행 전 확인이 필요함. `approval_resolve` 후 `operation_resume`. 추가 권한 부여로 보지 않기 | +| `APPROVAL_CONFLICT` | 홀드가 아직 대기 중이거나 거절되었거나, 재개가 이미 진행 중임 | +| `APPROVAL_AMBIGUOUS` | 재개가 중단되어 단말 결과를 모름. exec는 다시 spawn하지 않기. 패치는 `operation_status`로 복구될 수 있음 | | 패치 응답을 받지 못함 | 원래 키 또는 알고 있는 작업 ID로 `operation_status` 조회 | | `VERSION_CONFLICT` | 현재 파일을 읽고 새 패치 작성. 이전 패치를 강제로 적용하지 않기 | | `OPERATION_KEY_CONFLICT` | 다른 인자에 사용된 키이므로 이전 요청 확인 | diff --git a/docs/ko/architecture.md b/docs/ko/architecture.md index e100892..163efa3 100644 --- a/docs/ko/architecture.md +++ b/docs/ko/architecture.md @@ -39,11 +39,11 @@ UDS worker와 기본 러너는 모두 같은 호스트에서 실행합니다. | `server` | MCP 전송, HTTP 인증·inbox, 요청 검증과 실행 연결 | | `domain` | CodeSpace 도구 인자·결과, ID, 오류·실행 타입 | | `policy` | 등록 경로, 환경, 권한 프로필, 네트워크 정책 | -| `store` | SQLite의 패치 작업·논리적 작업·사용자 지시, 메모리의 점유 상태 | +| `store` | SQLite의 패치 작업·확인 홀드·논리적 작업·사용자 지시, 메모리의 점유 상태 | | `runner` | 실행 데이터 타입, 파일 범위, 패치 처리, 프로세스 관리, UDS 통신 | | 분리된 어댑터 | Codex 패치·PTY·파일 시스템·worker 보호와 소켓·Linux 샌드박스 구현 연결 | -패치 작업과 작업·지시 큐는 SQLite 파일을 설정한 경우에만 재시작 후 유지됩니다. 프로세스 핸들과 점유 상태는 메모리에만 있습니다. `operation_status`는 명령 실행을 조회하지 않습니다. 전송 요청 ID, 패치 작업 ID, 프로세스 ID, 작업 ID, 지시 ID는 서로 다른 대상을 가리킵니다. +패치 작업, 확인 홀드, 작업·지시 큐는 SQLite 파일을 설정한 경우에만 재시작 후 유지됩니다. 프로세스 핸들과 점유 상태는 메모리에만 있습니다. `operation_status`는 명령 실행을 조회하지 않습니다. 전송 요청 ID, 패치 작업 ID, 프로세스 ID, 작업 ID, 지시 ID, 승인 ID는 서로 다른 대상을 가리킵니다. @@ -65,7 +65,9 @@ MCP 요청이 끝나도 관리 중인 프로세스는 유지됩니다. 클라이 ## 확장 시 유지할 경계 -핵심 계층은 Codex 타입을 직접 가져오지 않습니다. 어댑터가 여러 Codex 실행 구성 요소에 의존할 수는 있지만 게이트웨이가 Codex 에이전트가 되는 것은 아닙니다. 실행 환경은 운영자가 설정하고, MCP 클라이언트는 등록된 작업 공간만 선택합니다. 컨테이너 실행, 원격 러너, 승인 후 재개 도구, 자원 스케줄러는 아직 구현되지 않았습니다. +핵심 계층은 Codex 타입을 직접 가져오지 않습니다. 어댑터가 여러 Codex 실행 구성 요소에 의존할 수는 있지만 게이트웨이가 Codex 에이전트가 되는 것은 아닙니다. 실행 환경은 운영자가 설정하고, MCP 클라이언트는 등록된 작업 공간만 선택합니다. 컨테이너 실행, 원격 러너, 자원 스케줄러는 아직 구현되지 않았습니다. + +확인 홀드 도구(`approval_create`, `approval_resolve`, `operation_resume`)는 구현되어 있습니다. 프로필이 이미 허용한 변경을 홀드가 승인될 때까지 멈춥니다. 보안 경계가 아닙니다. `read-only`를 쓰기·실행으로 올리거나 `ClientClaims.approved`를 인정하거나 프로필을 바꾸지 않습니다. 같은 MCP 호출자가 grant할 수 있습니다. 재개 시 정책을 다시 검사합니다. v1은 호스트와 모델을 구분하지 않습니다. 새 전송 방식을 추가하더라도 패치 처리는 하나의 Runner 호출로 유지합니다. Codex 사용자·세션 권한을 실행 허용의 근거로 가져오지 않고 게이트웨이에서 결정합니다. 현재 불변 조건은 [실행 계약](execution-substrate.md), 연결된 어댑터는 [Codex 재사용 범위](codex-reuse.md)에 정리되어 있습니다. diff --git a/docs/ko/error-codes.md b/docs/ko/error-codes.md index 9dcd720..7aee5ae 100644 --- a/docs/ko/error-codes.md +++ b/docs/ko/error-codes.md @@ -19,7 +19,7 @@ ## 도구 오류 코드 -오류는 대문자 식별자와 메시지로 전달합니다. 실패 전에 패치 기록을 만들었다면 `operation_id`가 포함될 수 있습니다. 그 전에 발생한 권한·점유·키 충돌 오류에는 작업 ID가 없습니다. +오류는 대문자 식별자와 메시지로 전달합니다. 실패 전에 패치 기록을 만들었다면 `operation_id`가 포함될 수 있습니다. 확인 홀드 오류에는 `approval_id`가 있습니다. 그 전에 발생한 권한·점유·키 충돌 오류에는 작업 ID가 없습니다. | 코드 | 의미 | | --- | --- | @@ -50,6 +50,10 @@ | `INTENT_NOT_EDITABLE` | 지시 상태가 편집을 허용하지 않음 | | `INTENT_REVISION_CONFLICT` | 지시 수정 버전이 달라짐 | | `QUEUE_NOT_EMPTY` | 예약된 코드. 현재 work_finish는 closed:false를 반환함 | +| `APPROVAL_REQUIRED` | 정책은 허용했으나 실행 전 확인이 필요함. `approval_id` 포함. 권한 부여가 아님 | +| `APPROVAL_NOT_FOUND` | 알 수 없는 확인 홀드 ID | +| `APPROVAL_CONFLICT` | 홀드가 아직 대기 중이거나 이미 결정되었거나, 재개가 이미 진행 중임 | +| `APPROVAL_AMBIGUOUS` | 재개가 중단되어 단말 결과가 디스크에 없음. `approval_id` 포함. 패치는 `operation_id`도 있을 수 있음 | ## 실행 요청과 완료의 구분 diff --git a/docs/ko/execution-substrate.md b/docs/ko/execution-substrate.md index 8a4176f..076a7d2 100644 --- a/docs/ko/execution-substrate.md +++ b/docs/ko/execution-substrate.md @@ -30,6 +30,7 @@ | 권한 프로필 | 파일·프로세스 행동의 허용 범위를 게이트웨이가 결정 | | 패치 작업 | `operation_id`와 선택적 중복 실행 방지 키, `files`/`changes` 해시, minted/finished 이벤트로 저장하는 패치 원장. `operation_status`로 조회. 명령 실행은 추적하지 않음 | | 프로세스 | 서버가 발급하는 명령 핸들. 메모리에만 보관 | +| 확인 홀드 | 운영자 `approvals` 설정. `approvals` 테이블 행(`pending`/`granted`/`resuming`, 단말 결과가 있을 때까지). 패치 작업이 아니며 권한 부여나 격리 경계도 아님 | | 논리적 작업 | 작업과 사용자 지시 큐. 전송 세션과 별개 | `environment_id`는 MCP 도구 인자가 아닙니다. 모델이 전달한 프록시 URL이나 Codex 사용자 설정이 실행 권한의 근거가 되지 않습니다. @@ -58,9 +59,15 @@ UDS 전송과 Linux 샌드박스 준비는 서로 다른 프로토콜과 실패 +## 확인 홀드 + +`approval_create`, `approval_resolve`, `operation_resume`을 호출할 수 있습니다. 작업 공간 프로필이 이미 허용한 변경을 홀드가 승인될 때까지 멈춥니다. 보안 경계가 아닙니다. 권한을 높이거나 `{ "network": true }`·`ClientClaims.approved`를 적용하거나 패치 원장에 V4A 스냅샷을 넣지 않습니다. 같은 MCP 호출자가 grant할 수 있습니다. + +운영자가 작업 공간 `approvals`를 `confirm`으로 두면, 정책이 허용한 `apply_patch`와 `exec_command`는 `begin()`이나 프로세스 시작 전에 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 같은 논리 요청을 다시 보내면 그 활성 홀드를 재사용합니다. 기본값 `off`에서는 해당 도구가 바로 실행됩니다. 세 도구는 목록에 남아 있으므로 명시적 `approval_create`로 홀드를 만들 수 있습니다. grant는 프로필을 바꾸지 않습니다. 재개는 `granted`를 `resuming`으로 옮긴 뒤 `allow()`를 다시 검사하고 기존 패치·실행 내부 경로를 돌립니다. `consumed`는 단말 결과와 함께만 기록됩니다. 이후 재개는 그 결과, 패치 원장 복구, 또는 `APPROVAL_AMBIGUOUS`를 반환합니다. 중단된 exec는 다시 spawn하지 않습니다. 정책 거절은 그대로 `UNAUTHORIZED`입니다. 명령은 `process_id`로 다룹니다. v1은 호스트와 모델을 구분하지 않습니다. 서버가 보장하는 것은 재개 시 정책 재검사와 이 내구성 계약입니다. + ## 아직 제공하지 않는 기능 -MCP에는 프로세스 종료 코드와 명시적 출력 유실 정보가 없습니다. PTY 크기 변경, 파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 승인 후 재개 도구, 자원 큐 스케줄러도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. +MCP에는 프로세스 종료 코드와 명시적 출력 유실 정보가 없습니다. PTY 크기 변경, 파일 범위·페이지 인자, 영속적인 프로세스 복구, 컨테이너·원격 실행, 자원 큐 스케줄러도 제공하지 않습니다. 내부 타입이나 협상된 프로토콜 플래그가 존재한다고 해당 기능을 호출할 수 있는 것은 아닙니다. ## 구현 경계 유지 diff --git a/docs/ko/operations.md b/docs/ko/operations.md index 801930d..3ea82e7 100644 --- a/docs/ko/operations.md +++ b/docs/ko/operations.md @@ -49,7 +49,8 @@ cp crates/linux-sandbox/target/release/codespace-linux-sandbox dist/ "demo": { "root": "/absolute/path/to/project", "profile": "workspace-write", - "network": "restricted" + "network": "restricted", + "approvals": "off" } } } @@ -59,6 +60,8 @@ CodeSpace 저장소의 `workspaces.json`으로 저장합니다. `read-only`는 `network` 기본값은 `restricted`입니다. `enabled`를 사용하려면 Linux 도우미가 필요하며, 지원되는 HTTP 통신은 관리 프록시를 거칩니다. 호스트 네트워크에 무제한 접근하는 설정이 아닙니다. 도우미를 사용할 수 없으면 `enabled` 실행은 실패합니다. `restricted`이고 도우미가 없으면 호스트 실행은 가능하지만 네트워크 제한은 OS 수준에서 강제되지 않습니다. 실제 환경의 적합성은 응답의 정책 집행 상태를 확인해 판단하세요. +`approvals` 기본값은 `off`이며, 정책이 허용한 패치와 명령을 바로 실행합니다. `confirm`이면 `begin()`이나 프로세스 시작 전에 해당 도구를 홀드하고 `APPROVAL_REQUIRED`와 `approval_id`를 반환합니다. 같은 패치나 exec를 다시 보내면 활성 홀드를 재사용합니다. `network`와 같은 운영자 JSON이며 MCP 도구 인자가 아니고 프로필을 올리지 않습니다. 확인 행은 패치 원장과 다른 `approvals` 테이블에 저장되며 `CODESPACE_OPERATIONS_DB`를 같이 씁니다. 홀드가 `pending`·`granted`·`resuming`인 동안 이 테이블은 V4A 패치나 exec argv를 보관합니다. `denied` 또는 `consumed` 뒤에는 본문을 digest 메타(도구, 작업 공간, fingerprint)로 바꿉니다. 패치 원장은 패치 본문이 아니라 해시를 보관합니다. 확인과 재개는 [Agent Loop 연동](agent-integration.md)을 참고하세요. + @@ -128,9 +131,9 @@ worker는 같은 호스트에서 실행하는 별도 프로세스이며 컨테 | 프로세스 출력 | 마지막 256 KiB 보관. stdout/stderr를 합치며 MCP 결과에 유실 표시와 종료 코드가 없음 | | 종료된 핸들 | 기본 최대 15분, 최대 64개 보관. 영구 저장하지 않음 | -로그와 데이터베이스는 관리 대상 작업 공간 밖에 두세요. stderr 로그의 보관·순환은 운영자가 관리합니다. Bearer 토큰을 로그나 커밋에 넣지 마세요. 데이터베이스를 삭제하면 패치 중복 실행 방지 기록도 사라집니다. +로그와 데이터베이스는 토큰·게이트웨이 설정과 같이 관리 대상 작업 공간 밖에 두세요. stderr 로그의 보관·순환은 운영자가 관리합니다. Bearer 토큰을 로그나 커밋에 넣지 마세요. 데이터베이스를 삭제하면 패치 중복 실행 방지 기록과 확인 홀드 행도 사라집니다. -패치 응답을 받지 못했다면 `operation_id` 또는 `operation_key` 중 하나만 지정해 `operation_status`를 조회합니다. 재시작 후 미완료 기록은 `unknown`이 되므로 파일을 확인한 뒤 다음 행동을 결정하세요. 프로세스는 `process_id`로 관리하며 `operation_status`로 복구할 수 없습니다. [재시도와 복구 규칙](agent-integration.md)을 참고하세요. +패치 응답을 받지 못했다면 `operation_id` 또는 `operation_key` 중 하나만 지정해 `operation_status`를 조회합니다. 재시작 후 미완료 기록은 `unknown`이 되므로 파일을 확인한 뒤 다음 행동을 결정하세요. 프로세스는 `process_id`로 관리하며 `operation_status`로 복구할 수 없습니다. 프로세스가 죽을 때 `resuming`이던 확인 홀드는 패치 원장에서 복구하거나, 저장된 단말 결과를 재현하거나, `APPROVAL_AMBIGUOUS`를 반환할 수 있습니다. exec는 다시 spawn하지 않습니다. [재시도와 복구 규칙](agent-integration.md)을 참고하세요. diff --git a/docs/ko/security-model.md b/docs/ko/security-model.md index 6279012..2cb5971 100644 --- a/docs/ko/security-model.md +++ b/docs/ko/security-model.md @@ -44,6 +44,8 @@ Linux 도우미가 없으면 restricted 정책의 작업 공간에서도 비격 버전 검사는 예상하지 못한 파일 버전에 패치를 적용하는 것을 막습니다. 작업 키는 패치 중복 요청을 구분하며 인증 토큰이 아닙니다. 작업 공간 점유는 파일을 변경할 수 있는 패치·명령 실행을 직렬화합니다. 큐 스케줄러와 영속적인 프로세스 복구는 없습니다. +`CODESPACE_OPERATIONS_DB`의 `approvals` 확인 홀드는 행이 `pending`·`granted`·`resuming`인 동안 V4A 패치나 exec argv를 보관합니다. 패치 원장은 패치 본문이 아니라 해시를 보관합니다. `denied` 또는 `consumed` 뒤에는 홀드 본문을 digest 메타(도구, 작업 공간, fingerprint)로 바꿉니다. 이 데이터베이스는 토큰·게이트웨이 설정과 같이 작업 공간 루트 밖에 두세요. 홀드는 워크플로 일시정지이며 같은 MCP 호출자가 grant할 수 있습니다. 격리 경계가 아닙니다. + 패치 스냅샷 복원은 가능한 범위에서 수행하며 모든 실패의 롤백을 보장하지 않습니다. `unknown`, 부분 실패, 적용 후 검증 오류가 발생하면 해당 파일을 확인하세요. [패치 동작](behavior-differences.md)과 [연동 복구 규칙](agent-integration.md)을 참고하세요. diff --git a/docs/operations.md b/docs/operations.md index 1d59fbe..e033ead 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -45,7 +45,8 @@ Create an existing project directory and a registry outside it. Replace `/absolu "demo": { "root": "/absolute/path/to/project", "profile": "workspace-write", - "network": "restricted" + "network": "restricted", + "approvals": "off" } } } @@ -55,6 +56,8 @@ Save this as `workspaces.json` in the CodeSpace checkout. `read-only` permits re `network` defaults to `restricted`. `enabled` requires the Linux helper and routes supported HTTP traffic through its managed proxy; it does not grant unrestricted host networking. Without a working helper, `enabled` execution fails. With `restricted` and no helper, host execution is possible but network restrictions are not OS-enforced. Use the reported enforcement state when deciding whether an environment is suitable. +`approvals` defaults to `off`, which runs policy-allowed patches and commands immediately. `confirm` holds those tools before `begin()` or spawn and returns `APPROVAL_REQUIRED` with an `approval_id`. Retrying the same patch or exec reuses the active hold. It is operator JSON like `network`, not an MCP argument, and it does not escalate the profile. Confirmation rows share `CODESPACE_OPERATIONS_DB` in an `approvals` table, separate from the patch operations ledger. While a hold is `pending`, `granted`, or `resuming`, that table stores the V4A patch or exec argv. After `denied` or `consumed`, the body is replaced with digest metadata (tool, workspace, fingerprint). The patch ledger keeps hashes, not patch text. See [Agent Loop integration](agent-integration.md) for resolve and resume. + ## Start the server @@ -120,9 +123,9 @@ The worker runs on the same host and is not a container. The gateway creates a p | Process output | Last 256 KiB retained; stdout/stderr combined; no explicit loss flag or exit code in MCP results | | Completed handles | Default retention up to 15 minutes and 64 completed entries; not durable | -Store logs and the database outside the managed workspace. Rotate stderr capture yourself. Do not log Bearer tokens or commit real credentials. Deleting the database also deletes patch idempotency records. +Store logs and the database outside the managed workspace, with tokens and gateway configuration. Rotate stderr capture yourself. Do not log Bearer tokens or commit real credentials. Deleting the database also deletes patch idempotency records and confirmation-hold rows. -After losing a patch response, query `operation_status` with exactly one of `operation_id` or `operation_key`. An unfinished record becomes `unknown` after restart; inspect files before deciding what to do. Processes use `process_id` and cannot be recovered through `operation_status`. See [retry and recovery rules](agent-integration.md). +After losing a patch response, query `operation_status` with exactly one of `operation_id` or `operation_key`. An unfinished record becomes `unknown` after restart; inspect files before deciding what to do. Processes use `process_id` and cannot be recovered through `operation_status`. A confirmation hold that was `resuming` when the process died may recover a patch from that ledger, replay a stored terminal result, or return `APPROVAL_AMBIGUOUS`; exec is not respawned. See [retry and recovery rules](agent-integration.md). diff --git a/docs/security-model.md b/docs/security-model.md index 48a6640..ba956d8 100644 --- a/docs/security-model.md +++ b/docs/security-model.md @@ -34,6 +34,8 @@ The operator-registered root is a trust anchor. Keep tokens, the operations data Version checks prevent applying a patch to an unexpected file version. Operation keys detect duplicate patch requests; they are not authorization tokens. Workspace occupancy serializes mutating patch and exec work. There is no queue scheduler or durable process recovery. +Confirmation holds in `CODESPACE_OPERATIONS_DB` (`approvals`) store the V4A patch or exec argv while the row is `pending`, `granted`, or `resuming`. The patch operations ledger stores hashes, not patch text. After `denied` or `consumed`, the hold body is replaced with digest metadata (tool, workspace, fingerprint). Keep this database outside the workspace root, with tokens and gateway configuration. The hold is a workflow pause: the same MCP caller can grant it. It is not an isolation boundary. + Patch snapshot restoration is best effort and is not an all-failure rollback guarantee. On `unknown`, partial failure, or post-apply verification error, inspect affected files. See [patch behavior](behavior-differences.md) and [integration recovery rules](agent-integration.md). diff --git a/docs/translations.json b/docs/translations.json index 4ca54cd..aaf5dde 100644 --- a/docs/translations.json +++ b/docs/translations.json @@ -86,6 +86,7 @@ "translation": "docs/ko/agent-integration.md", "anchors": [ "agent-loop-연동", + "confirm-a-held-mutation", "connect-an-agent-loop", "connect-and-inspect-capabilities", "handle-user-instructions-and-finish", @@ -93,13 +94,14 @@ "retry-and-recover-deliberately", "run-and-observe-a-command", "명령-실행과-결과-확인", + "보류된-변경-확인", "사용자-지시-처리와-완료", "연결과-기능-확인", "재시도와-복구", "파일-읽기와-패치" ], - "source_sha256": "b02d9dc73657bafcebf7a13a29126570d3876fa4fa61f40e4da3bedb1b5146ca", - "translation_sha256": "8b1b6cd9fe4fd71247506e5b65598726154de486e70b7ccc1b98573979add0bf" + "source_sha256": "f00270e95b2bc084a9bacf7b9deeb2d9027c74f3debbd09e7f0d5fe629a157c8", + "translation_sha256": "0303b0f054ece5e2a7c4ea2c2a78e18247c578b53420ab53352a7f6e682066f5" }, { "id": "operations", @@ -150,8 +152,8 @@ "작업-공간-등록", "첫-연결-확인" ], - "source_sha256": "984ff0379f77362949c7ce1202d2f91485dd5fc623941199c179f021db78637f", - "translation_sha256": "7a85ef6419c22c789ce6135cee83170418aad9d8bbda7fc89da4813c04ac69ef" + "source_sha256": "2b4070638407f6cf73a0c18d65edb8e8bc76d7cb0e30708ac8dab2d04fbc50ab", + "translation_sha256": "47adcf45d36bd8e054e5351b564e60d5962cec35f3a853e03450e7bed6b693de" }, { "id": "chatgpt-connector", @@ -238,8 +240,8 @@ "현재-실행-구조", "확장-시-유지할-경계" ], - "source_sha256": "3e7a03a870203509f889b55470e67ee7521e19520e301fc2f3488d10b90bdf88", - "translation_sha256": "9ee671b1075bacf41e815b24486e6a81b6ba8514163d4a3a46713cb99ff45b0e" + "source_sha256": "e7bc2b2a86075d17f7eecb672f979dc239b5463688db976277303c511be7f19c", + "translation_sha256": "555724dfeaabd4c7cc7d8e2beafa92c15235389bc009047cd2e064d91ca9bec2" }, { "id": "execution-substrate", @@ -254,6 +256,7 @@ "command-exec-형태-대-크레이트", "commandexec-shape-vs-crates", "commandexec-형태-대-크레이트", + "confirmation-holds", "environment-and-identity", "execution-and-observation", "execution-contracts", @@ -295,10 +298,11 @@ "실행과-결과-관측", "아직-제공하지-않는-기능", "정책과-실행-구현", + "확인-홀드", "훅과-스킬" ], - "source_sha256": "ff5359e03a5a5e5b612316ca7ba9401a2cdd275e3a1eac127d6a7afa12ade9ea", - "translation_sha256": "ba8ce538b22e3c79343548cdf8a0ffa67665e3f7a02d7767d350360c965df33a" + "source_sha256": "0cd55e60c0ea0e71c3d44fedaa0bd2e07c6aea3cf150bb9c716a990137414510", + "translation_sha256": "4373d96a0b266706e98fb3728140169838d22edcd0938c018f93c7b792d6566a" }, { "id": "protocol-compatibility", @@ -408,8 +412,8 @@ "프로세스-정직성", "프로필-mvp" ], - "source_sha256": "3ba23fbe3b884062601e9484c87a24dd52a3e2aa618598b8b83c01865a1a007b", - "translation_sha256": "c64b086cf638757a1462ae02f84173f2628e0bcf1c55cd78a3ce607c76fdb234" + "source_sha256": "c96676f26bb76b12fdd7f9a73c6a114f7b8dda40d798a070e6194602f642893d", + "translation_sha256": "11363cc1c6a3b0b94cc4d58e7ece7a57d27db4ecf3d509bce6e378847eaf4861" }, { "id": "runner-isolation", @@ -468,8 +472,8 @@ "전송-실패", "전송-실패-작업-없음" ], - "source_sha256": "1bd9af29f2af3a0a56e4962264079941b50144cf29914477e82f18f578cc4df1", - "translation_sha256": "0a89b1267b5040f3c614acef681072112a9881939e18a40febebde1e6203bfb2" + "source_sha256": "fdd866544ae59e232ebd64e9f24d533a2cdd6d082edb813ab195722f220e2119", + "translation_sha256": "596d66ecaf86dcb4f57a314bbb6b9abba4ec70f77a701a6272f2df64cc63b9d3" }, { "id": "codex-reuse",