diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index a632d81c8..e7e78dffc 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -5047,6 +5047,65 @@ mod tests { const SAVED_SUBJEKTIV_SYSTEM_PROMPT: &str = "Committed saved Subject Worker system prompt"; + // Substituted Host transport for restore/controller component tests. This + // fixture has exactly the two activation grants requested by the saved + // Manifest; it grants no protected operations or resources. It does not + // exercise authenticated source derivation, Subject policy, or revocation + // in the real Workspace Host. + fn saved_subjektiv_authorization_fixture( + input: server_api::authorization::OperationAuthorizationRequest, + ) -> Result { + use server_api::authorization::{OperationAuthorizationResponse, PermissionResource}; + if input.validate().is_ok() + && matches!(input.operation.as_str(), "subjektiv" | "workdir_catalog") + && input.resource == PermissionResource::Feature + { + Ok(OperationAuthorizationResponse { + scope: Some(PermissionResource::Feature), + }) + } else { + Err("saved Subject fixture has no matching exact Feature grant") + } + } + + #[test] + fn saved_subjektiv_authorization_fixture_grants_only_exact_feature_activation() { + use server_api::authorization::{OperationAuthorizationRequest, PermissionResource}; + for operation in ["subjektiv", "workdir_catalog"] { + let response = saved_subjektiv_authorization_fixture(OperationAuthorizationRequest { + operation: operation.into(), + resource: PermissionResource::Feature, + }) + .unwrap(); + assert_eq!(response.scope, Some(PermissionResource::Feature)); + } + for (operation, resource) in [ + ("ticket", PermissionResource::Feature), + ("subjektiv.journal", PermissionResource::Feature), + ("subjektiv.*", PermissionResource::Feature), + ( + "subjektiv", + PermissionResource::Workspace { + workspace_id: "workspace-saved-subjektiv".into(), + }, + ), + ( + "subjektiv.journal", + PermissionResource::Workspace { + workspace_id: "workspace-saved-subjektiv".into(), + }, + ), + ] { + assert!( + saved_subjektiv_authorization_fixture(OperationAuthorizationRequest { + operation: operation.into(), + resource, + }) + .is_err() + ); + } + } + fn saved_subjektiv_policy( root: &Path, worker_ref: &WorkerRef, @@ -5211,6 +5270,25 @@ mod tests { })) }), ); + let app = app.route( + "/api/w/workspace-saved-subjektiv/authorization/check", + axum::routing::post( + |axum::Json(input): axum::Json< + server_api::authorization::OperationAuthorizationRequest, + >| async move { + match saved_subjektiv_authorization_fixture(input) { + Ok(response) => ( + axum::http::StatusCode::OK, + axum::Json(serde_json::to_value(response).unwrap()), + ), + Err(message) => ( + axum::http::StatusCode::FORBIDDEN, + axum::Json(serde_json::json!({ "message": message })), + ), + } + }, + ), + ); tokio::spawn(async move { axum::serve(listener, app).await.unwrap(); }); @@ -6865,10 +6943,10 @@ mod tests { working_directory_id: workdir_id, capabilities: WorkdirSessionCapabilities::READ_ONLY, }]; - let client = WorkerWorkspaceContext::local_filesystem(Some( - WorkspaceId::new("workspace-a").unwrap(), - )) - .client_handle(); + // Direct local-router capability test: no Workspace Host transport is + // installed. Use an explicit standalone binding, not an unavailable + // Workspace-bound client pretending to authorize operations. + let client = WorkerWorkspaceContext::no_workspace().client_handle(); let router = restored_workdir_router(&attachments, &logical, &[], client).unwrap(); @@ -7473,6 +7551,91 @@ mod tests { ); } + // Generic controller tests explicitly substitute the Host's non-Subject + // legacy decision. No Subject authority is modeled here; all other requests + // still use the original transport and its Backend/provider checks. + #[derive(Debug)] + struct LegacyNonSubjectAuthorizationClient(Arc); + + impl WorkspaceClient for LegacyNonSubjectAuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + self.0.workspace_id() + } + + fn kind(&self) -> &str { + self.0.kind() + } + + fn is_available(&self) -> bool { + self.0.is_available() + } + + fn execute( + &self, + request: worker::WorkspaceRequest, + ) -> Result { + if request.method == worker::WorkspaceRequestMethod::Post + && request.path + == format!( + "/api/w/{}/authorization/check", + self.workspace_id() + .expect("legacy fixture needs Workspace identity") + ) + { + let input: server_api::authorization::OperationAuthorizationRequest = + serde_json::from_str(request.body.as_deref().unwrap_or_default()).map_err( + |error| worker::WorkspaceClientError::Request(error.to_string()), + )?; + input + .validate() + .map_err(worker::WorkspaceClientError::Request)?; + return Ok(worker::WorkspaceResponse { + status: 200, + body: serde_json::to_string( + &server_api::authorization::OperationAuthorizationResponse { scope: None }, + ) + .unwrap(), + }); + } + self.0.execute(request) + } + } + + #[test] + fn legacy_non_subject_authorization_fixture_preserves_other_transport_failures() { + use server_api::authorization::PermissionResource; + let client = LegacyNonSubjectAuthorizationClient( + WorkerWorkspaceContext::local_filesystem(Some(WorkspaceId::new("ws-test").unwrap())) + .client_handle(), + ); + assert_eq!( + client + .authorize_operation_scope("workdir_catalog", &PermissionResource::Feature) + .unwrap(), + None, + ); + assert_eq!( + client + .authorize_operation_scope( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "ws-test".into(), + } + ) + .unwrap(), + None, + ); + assert!( + client + .authorize_operation("ticket.*", &PermissionResource::Feature) + .is_err() + ); + assert!(matches!( + client.execute(worker::WorkspaceRequest::get("/api/w/ws-test/tickets")), + Err(worker::WorkspaceClientError::Unavailable(_)), + )); + } + struct MockFactory { client: MockClient, runtime_base: PathBuf, @@ -7538,6 +7701,22 @@ mod tests { None, None, ); + let workspace_context = if workspace_context.client().is_available() + && workspace_context.workspace_id().is_some() + { + assert!( + !request.request.subjektiv_attached, + "generic legacy fixture must never authorize a Subject Worker" + ); + WorkerWorkspaceContext::with_client( + workspace_context.workspace_id().cloned(), + Arc::new(LegacyNonSubjectAuthorizationClient( + workspace_context.client_handle(), + )), + ) + } else { + workspace_context + }; let workspace_client = workspace_context.client_handle(); self.observed_workspace_clients.lock().unwrap().push(( workspace_client.kind().to_string(), diff --git a/crates/worker/src/authorized_workdir.rs b/crates/worker/src/authorized_workdir.rs index 3da819ff3..6967f0db0 100644 --- a/crates/worker/src/authorized_workdir.rs +++ b/crates/worker/src/authorized_workdir.rs @@ -417,11 +417,6 @@ impl WorkdirSession for AuthorizedWorkdirSession { async fn start_command(&self, request: CommandRequest) -> Result { // Validate coordinates without treating cwd as the command's access scope. WorkdirPath::new(request.cwd.as_str()).map_err(WorkdirError::from)?; - if request.spill_dir.is_some() { - return Err(WorkdirError::Denied( - "provider-local command spill paths have no attachment resource mapping".into(), - )); - } self.authorize_command()?; self.session.start_command(request).await } @@ -609,9 +604,8 @@ mod tests { CommandRequest { command: command.into(), timeout_secs: 10, - output_limit: 4096, + transport_byte_budget: 4096, cwd: WorkdirPath::root(), - spill_dir: None, tool_call_id: None, } } @@ -786,7 +780,7 @@ mod tests { } #[tokio::test] - async fn unmappable_absolute_or_spill_paths_are_denied_before_local_effects() { + async fn unmappable_absolute_paths_are_denied_before_local_effects() { let root = tempfile::tempdir().unwrap(); let client = AuthorizationClient::new(vec![ permission(WRITE, ".", true), @@ -804,15 +798,7 @@ mod tests { .await, Err(WorkdirError::Denied(_)) )); - let mut request = command("printf bad > escaped"); - request.spill_dir = Some(root.path().join("spill")); - assert!(matches!( - session.start_command(request).await, - Err(WorkdirError::Denied(_)) - )); assert!(!root.path().join("absolute").exists()); - assert!(!root.path().join("escaped").exists()); - assert!(!root.path().join("spill").exists()); assert!(client.requests.lock().unwrap().is_empty()); session.close().await.unwrap(); } diff --git a/crates/workspace-server/src/server/workdir_authorization.rs b/crates/workspace-server/src/server/workdir_authorization.rs index d6ddb66de..767f8b862 100644 --- a/crates/workspace-server/src/server/workdir_authorization.rs +++ b/crates/workspace-server/src/server/workdir_authorization.rs @@ -134,13 +134,6 @@ fn authorize_permissions( // Validating cwd is not a shell confinement mechanism. The command can cd elsewhere, // so a prefix grant (even one covering cwd recursively) is never enough. resource(working_directory_id, COMMAND, &request.cwd, false)?; - if request.spill_dir.is_some() { - // This is a provider-local PathBuf, not an attachment-relative WorkdirPath. - // Do not authorize an unrepresentable write destination as attachment scope. - return Err(denied( - "caller-selected provider-local command spill paths are unsupported", - )); - } check(COMMAND, &WorkdirPath::root(), true) } WorkdirSessionOperation::CommandOutput(_) => { @@ -233,9 +226,8 @@ mod tests { WorkdirSessionOperation::CommandStart(CommandRequest { command: "cd ..; cat secret".into(), timeout_secs: 1, - output_limit: 1024, + transport_byte_budget: 1024, cwd: path(cwd), - spill_dir: None, tool_call_id: None, }) } @@ -427,14 +419,6 @@ mod tests { assert!(!allowed(&[grant(COMMAND, ".", false)], &command("src"))); assert!(!allowed(&[grant(WRITE, ".", true)], &command("src"))); assert!(allowed(&[grant(COMMAND, ".", true)], &command("src"))); - let WorkdirSessionOperation::CommandStart(mut request) = command("src") else { - unreachable!() - }; - request.spill_dir = Some("/provider/private".into()); - assert!(!allowed( - &[grant(COMMAND, ".", true)], - &WorkdirSessionOperation::CommandStart(request) - )); } #[test]