From b135fa5e99c731711e7fe559293457da7d4e94a7 Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 20 Oct 2026 06:22:58 +0900 Subject: [PATCH] feat: enforce explicit Subject permissions and delegated Worker grants --- crates/client/src/backend_runtime.rs | 1 + crates/client/src/workspace_product.rs | 1 + crates/server-api/src/authorization.rs | 475 +++++ crates/server-api/src/lib.rs | 90 +- crates/subjektiv/src/api.rs | 8 + crates/subjektiv/src/legacy_migrations.rs | 2 +- crates/subjektiv/src/lib.rs | 16 + crates/subjektiv/src/permissions.rs | 288 ++++ crates/subjektiv/src/permissions.sql | 45 + crates/subjektiv/src/permissions/tests.rs | 656 +++++++ crates/tui/src/backend_spawn.rs | 1 + crates/worker-runtime/src/worker_backend.rs | 48 +- crates/worker/src/authorized_workdir.rs | 1129 ++++++++++++ crates/worker/src/bootstrap.rs | 14 + crates/worker/src/controller.rs | 29 +- crates/worker/src/feature.rs | 270 +++ .../src/feature/builtin/drive/delegation.rs | 20 + .../src/feature/builtin/manage_worker.rs | 163 +- .../src/feature/builtin/orchestration.rs | 1 + crates/worker/src/feature/builtin/ticket.rs | 368 +++- crates/worker/src/lib.rs | 1 + crates/worker/src/prompt/system.rs | 27 +- crates/worker/src/spawn/tool.rs | 6 + crates/worker/src/wip.rs | 11 +- crates/worker/src/worker.rs | 377 +++- crates/worker/tests/controller_test.rs | 22 +- crates/workspace-server/src/hosts.rs | 8 + crates/workspace-server/src/server.rs | 299 +++- .../src/server/subject_authorization.rs | 484 ++++++ .../src/server/tests/api_logging_tests.rs | 17 +- .../src/server/tests/drive_api_tests.rs | 1 + .../server/tests/drive_web_roundtrip_tests.rs | 1 + .../tests/restored_parent_control_tests.rs | 1 + .../tests/subject_authorization_tests.rs | 1530 +++++++++++++++++ .../src/server/tests/subjektiv_jobs_tests.rs | 1 + .../subjektiv_journal_connected_tests.rs | 52 +- .../server/tests/subjektiv_journal_tests.rs | 1 + .../tests/ticket_worker_launch_tests.rs | 2 + .../src/server/tests/worker_drive_tests.rs | 2 + .../src/server/workdir_authorization.rs | 537 ++++++ .../src/server_assignment_start_tests.rs | 1 + .../src/server_assignment_tracking_tests.rs | 2 + .../src/server_ticket_tools_backend_tests.rs | 29 +- .../src/server_workspace_config_wip_tests.rs | 1 + crates/workspace-server/src/store.rs | 1 + openapi/server-api.json | 463 ++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 2 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 10 +- 52 files changed, 7422 insertions(+), 100 deletions(-) create mode 100644 crates/server-api/src/authorization.rs create mode 100644 crates/subjektiv/src/permissions.rs create mode 100644 crates/subjektiv/src/permissions.sql create mode 100644 crates/subjektiv/src/permissions/tests.rs create mode 100644 crates/worker/src/authorized_workdir.rs create mode 100644 crates/workspace-server/src/server/subject_authorization.rs create mode 100644 crates/workspace-server/src/server/tests/subject_authorization_tests.rs create mode 100644 crates/workspace-server/src/server/workdir_authorization.rs diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index ee5957e52..5be0d0eab 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -908,6 +908,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let response = create_backend_worker_with_client(&target, &create, &api) diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 7aafe46e7..ad85206ce 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -567,6 +567,7 @@ impl BackendWorkspaceProductClient { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let workspace_id = self.workspace_id.clone(); let response: BrowserCreateWorkerResponse = self.generated(move |client| async move { diff --git a/crates/server-api/src/authorization.rs b/crates/server-api/src/authorization.rs new file mode 100644 index 000000000..6d3de37f1 --- /dev/null +++ b/crates/server-api/src/authorization.rs @@ -0,0 +1,475 @@ +//! Neutral explicit-allow authorization contracts. Resource containment never crosses kinds. +//! +//! # Trusted Feature boundary +//! +//! A Profile requests execution configuration, not authority. The Worker asks the +//! Host for each enabled Feature's exact activation name before initialization, +//! then trusted Feature code asks for each protected operation and its semantic +//! resource before access or effects. `ticket` on [`PermissionResource::Feature`] +//! activates Ticket; it implies neither `ticket.create` nor any future operation. +//! `ticket.create` on a Workspace permits creation there but cannot activate the +//! Feature. There are no wildcard permissions or implicit dotted-name inheritance. +//! +//! Identity is absent from the authorization request intentionally: the Host +//! authenticates the bound Runtime/Worker and resolves current source permissions, +//! body ownership and any per-Worker execution grant. A shared client, Profile, +//! remembered receipt or caller-supplied Subject claim is not authority. Features +//! depend on this contract, never on a particular Subject policy implementation. +//! +//! Supported cooperative boundaries are Feature activation, ordinary Worker +//! delegation, Ticket creation (Tool/native WIP/typed service), Workdir filesystem +//! read/write and whole-attachment command execution, and scoped result notify. +//! Workdir providers still enforce resolved paths, traversal, capability, Ticket +//! and provider ceilings. The returned scope is for provider narrowing, not a +//! transferable credential. Scoped checkout/overlap operations are refused until +//! their complete access semantics are adapted. Other Feature operations retain +//! their existing owner checks; they do not automatically acquire this operation +//! policy. Missing calls are trusted Feature bugs, not interception by a universal +//! Tool executor or protection against malicious Feature code. Connection tests +//! must exercise each exposed surface, assert a genuine permitted effect and +//! assert zero downstream calls on denial, including revocation and provider denial. +//! +//! # Delegation callers and recovery +//! +//! Callers such as a chat Feature supply explicit operation/resource selections +//! and retain the same public operation ID and full request after an unknown +//! result. Normal Profile/Worker allocation and control/resource grants are reused; +//! there is no Subject Worker registry. Effective grants bind immutable Profile +//! resolution, the reserved Worker and source assignment. Permission replacement +//! invalidates existing children rather than refreshing them. Body replacement, +//! retirement and restore must recheck current authority; historical result lookup +//! is not new execution permission. Cancellation acceptance is distinct from +//! terminal confirmation and does not undo completed effects. +//! +//! A child receives no Subject body identity, singleton, whole Memory or public +//! conversation-post authority. `worker.result` is explicit notify authority to a +//! named origin Worker, not arbitrary control or publication. Chat delivery and +//! conversation correlation belong to the caller integration, not this contract. +//! Unset existing Subject policies deny new Subject execution/delegation; ordinary +//! non-Subject Workers explicitly retain existing Backend/provider contracts. +//! Standalone non-Workspace activation compatibility grants no Workspace operation. +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +pub const MAX_SCOPED_PERMISSIONS: usize = 256; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum PermissionResource { + Feature, + Workspace { + workspace_id: String, + }, + Workdir { + working_directory_id: String, + path: String, + recursive: bool, + }, + Worker { + runtime_id: String, + worker_id: String, + }, +} + +// Serde's internally tagged unit variants ignore extra fields even with deny_unknown_fields. +// A struct-shaped wire variant preserves the public unit variant while rejecting those claims. +impl<'de> Deserialize<'de> for PermissionResource { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + #[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] + enum Wire { + Feature {}, + Workspace { + workspace_id: String, + }, + Workdir { + working_directory_id: String, + path: String, + recursive: bool, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + } + Ok(match Wire::deserialize(deserializer)? { + Wire::Feature {} => Self::Feature, + Wire::Workspace { workspace_id } => Self::Workspace { workspace_id }, + Wire::Workdir { + working_directory_id, + path, + recursive, + } => Self::Workdir { + working_directory_id, + path, + recursive, + }, + Wire::Worker { + runtime_id, + worker_id, + } => Self::Worker { + runtime_id, + worker_id, + }, + }) + } +} + +impl PermissionResource { + pub fn validate(&self) -> Result<(), String> { + match self { + Self::Feature => Ok(()), + Self::Workspace { workspace_id } => validate_authorization_identifier(workspace_id), + Self::Workdir { + working_directory_id, + path, + .. + } => { + validate_authorization_identifier(working_directory_id)?; + validate_relative_permission_path(path) + } + Self::Worker { + runtime_id, + worker_id, + } => { + validate_authorization_identifier(runtime_id)?; + validate_authorization_identifier(worker_id) + } + } + } + + pub fn covers(&self, other: &Self) -> bool { + if self.validate().is_err() || other.validate().is_err() { + return false; + } + match (self, other) { + (Self::Feature, Self::Feature) => true, + (Self::Workspace { workspace_id: a }, Self::Workspace { workspace_id: b }) => a == b, + ( + Self::Worker { + runtime_id: ar, + worker_id: aw, + }, + Self::Worker { + runtime_id: br, + worker_id: bw, + }, + ) => ar == br && aw == bw, + ( + Self::Workdir { + working_directory_id: a, + path: ap, + recursive: ar, + }, + Self::Workdir { + working_directory_id: b, + path: bp, + recursive: br, + }, + ) => { + a == b + && if *ar { + ap == "." + || ap == bp + || bp + .strip_prefix(ap) + .is_some_and(|tail| tail.starts_with('/')) + } else { + ap == bp && !br + } + } + _ => false, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct ScopedPermission { + pub operation: String, + pub resource: PermissionResource, +} + +impl ScopedPermission { + pub fn validate(&self) -> Result<(), String> { + validate_operation_name(&self.operation)?; + self.resource.validate()?; + if matches!(self.resource, PermissionResource::Feature) && self.operation.contains('.') { + return Err("Feature resources authorize activation names only, not operations".into()); + } + Ok(()) + } + + pub fn covers(&self, other: &Self) -> bool { + self.validate().is_ok() + && other.validate().is_ok() + && self.operation == other.operation + && self.resource.covers(&other.resource) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissions { + pub change_id: String, + pub execution: Vec, + pub delegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsUpdate { + pub operation_id: String, + pub expected_change_id: Option, + pub execution: Vec, + pub delegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerExecutionGrant { + pub subject_id: String, + pub origin_runtime_id: String, + pub origin_worker_id: String, + pub source_change_id: String, + pub runtime_id: String, + pub worker_id: String, + pub profile: String, + pub profile_digest: String, + pub permissions: Vec, + pub redelegation: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct WorkerDelegationRequest { + pub permissions: Vec, + pub redelegation: Vec, +} + +impl WorkerDelegationRequest { + pub fn validate(&self) -> Result<(), String> { + validate_scoped_permissions(&self.permissions)?; + validate_scoped_permissions(&self.redelegation) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct OperationAuthorizationRequest { + pub operation: String, + pub resource: PermissionResource, +} + +impl OperationAuthorizationRequest { + pub fn validate(&self) -> Result<(), String> { + ScopedPermission { + operation: self.operation.clone(), + resource: self.resource.clone(), + } + .validate() + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct OperationAuthorizationResponse { + /// Matching granted scope for provider-resolved checks, not a credential. + /// `None` identifies the explicit non-Subject legacy authorization path. + #[serde(default)] + pub scope: Option, +} + +pub fn validate_operation_name(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 256 + || !value.split('.').all(|part| { + !part.is_empty() + && part + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') + }) + { + return Err( + "permission operation must be an exact, nonempty dot-separated name (no wildcards)" + .into(), + ); + } + Ok(()) +} + +pub fn validate_scoped_permissions(permissions: &[ScopedPermission]) -> Result<(), String> { + if permissions.len() > MAX_SCOPED_PERMISSIONS { + return Err("too many scoped permissions".into()); + } + for permission in permissions { + permission.validate()?; + } + Ok(()) +} + +pub fn validate_authorization_identifier(value: &str) -> Result<(), String> { + if value.is_empty() + || value.len() > 256 + || value.trim() != value + || value.chars().any(|c| c.is_control() || c == '*') + { + return Err("authorization identity must be nonempty, bounded and exact".into()); + } + Ok(()) +} + +/// Paths are already-normalized lexical paths, not host filesystem paths. The Host must +/// still enforce symlink/provider boundaries; lexical containment grants no filesystem access. +pub fn validate_relative_permission_path(path: &str) -> Result<(), String> { + if path == "." { + return Ok(()); + } + if path.is_empty() + || path.len() > 4096 + || path.contains(['\\', ':']) + || path.chars().any(char::is_control) + || path + .split('/') + .any(|part| part.is_empty() || part == "." || part == "..") + { + return Err("permission path must be a normalized relative path without escape".into()); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn workdir(path: &str, recursive: bool) -> PermissionResource { + PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: path.into(), + recursive, + } + } + + #[test] + fn lexical_containment_rejects_escape_and_respects_component_and_recursion_boundaries() { + for path in [ + "", + "/src", + "../src", + "src/../secret", + "src/./a", + "src//a", + "src/", + "C:/src", + "src\\a", + ] { + assert!(workdir(path, true).validate().is_err(), "{path}"); + } + let root = workdir(".", true); + let src = workdir("src", true); + assert!(root.covers(&src)); + assert!(src.covers(&workdir("src/a", true))); + assert!(!src.covers(&workdir("src-other", false))); + assert!(!workdir("src", false).covers(&src)); + assert!(workdir("src", false).covers(&workdir("src", false))); + assert!(!workdir("src", false).covers(&workdir("src/a", false))); + assert!(!src.covers(&PermissionResource::Workdir { + working_directory_id: "other".into(), + path: "src".into(), + recursive: false + })); + } + + #[test] + fn operation_hierarchy_never_implies_inheritance_or_feature_activation() { + for name in [ + "", + "*", + "ticket.*", + ".ticket", + "ticket.", + "ticket..read", + "ticket read", + ] { + assert!(validate_operation_name(name).is_err(), "{name}"); + } + let activation = ScopedPermission { + operation: "ticket".into(), + resource: PermissionResource::Feature, + }; + let create = ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "ws".into(), + }, + }; + let parent_name = ScopedPermission { + operation: "ticket".into(), + resource: create.resource.clone(), + }; + assert!(!parent_name.covers(&create)); + assert!(!create.covers(&parent_name)); + assert!(!activation.covers(&create)); + assert!(!create.covers(&activation)); + assert!( + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Feature + } + .validate() + .is_err() + ); + assert!( + !PermissionResource::Workspace { + workspace_id: "ws".into() + } + .covers(&workdir(".", true)) + ); + } + + #[test] + fn wire_contract_rejects_self_claimed_identity_and_unknown_fields() { + let request = serde_json::json!({"permissions":[],"redelegation":[],"subject_id":"forged"}); + assert!(serde_json::from_value::(request).is_err()); + let resource: PermissionResource = + serde_json::from_value(serde_json::json!({"kind":"feature"})).unwrap(); + assert_eq!(resource, PermissionResource::Feature); + assert!( + serde_json::from_value::( + serde_json::json!({"kind":"feature","workspace_id":"ws"}) + ) + .is_err() + ); + } + + #[test] + fn authorization_response_carries_matching_scope_and_defaults_legacy_absence() { + let legacy: OperationAuthorizationResponse = + serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(legacy.scope, None); + assert_eq!( + serde_json::to_value(legacy).unwrap(), + serde_json::json!({"scope":null}) + ); + let granted = OperationAuthorizationResponse { + scope: Some(workdir("src", true)), + }; + let wire = serde_json::to_value(&granted).unwrap(); + assert_eq!( + wire, + serde_json::json!({"scope":{"kind":"workdir","working_directory_id":"wd","path":"src","recursive":true}}) + ); + assert_eq!( + serde_json::from_value::(wire).unwrap(), + granted + ); + } +} diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index bed58d4ef..5a4f9e50c 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -6,6 +6,7 @@ use api_macros::api; pub use api_macros::axum as server_support; pub use api_macros::reqwest as client_support; pub use api_macros::{ApiContract, BinaryBody, HttpMethod, TransportMetadata, WebSocketOperation}; +pub mod authorization; mod drive; mod notification; pub use notification::*; @@ -23,6 +24,34 @@ use webauthn_rs_proto::{ }; pub use workspace_config::*; +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsResponse { + pub permissions: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionsMutationResponse { + pub permissions: authorization::SubjectPermissions, + /// New operations are already fenced. These are cancellation requests and + /// Backend command cleanup evidence, not proof that a Runtime turn stopped. + pub interruptions: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(deny_unknown_fields)] +pub struct SubjectPermissionInterruption { + pub runtime_id: String, + pub worker_id: String, + pub cancellation_accepted: bool, + pub backend_command_sessions_closed: bool, + pub diagnostics: Vec, +} + #[allow(dead_code)] #[derive(JsonSchema)] struct ServerJsonSafeU64(#[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] u64); @@ -168,6 +197,11 @@ macro_rules! impl_openapi_schema { } impl_openapi_schema!( + SubjectPermissionsResponse, + SubjectPermissionsMutationResponse, + SubjectPermissionInterruption, + authorization::SubjectPermissions, + authorization::SubjectPermissionsUpdate, AuthBootstrapUserRequest, AuthPublicConfig, HostingSettings, @@ -1298,6 +1332,49 @@ pub trait ServerApi { #[body] request: MemoryConsolidateStagingRequest, ) -> Result; + #[post( + "/api/w/{workspace_id}/authorization/check", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + openapi = false, + )] + async fn operation_authorize( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: authorization::OperationAuthorizationRequest, + ) -> Result; + + #[get( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 500], + browser_auth = true, + )] + async fn subjektiv_subject_permissions_get( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] subject_id: String, + ) -> Result; + + #[post( + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions", + status = 200, + error_status = 400, + additional_error_statuses = [401, 403, 404, 409, 500], + browser_auth = true, + )] + async fn subjektiv_subject_permissions_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] subject_id: String, + #[body] request: authorization::SubjectPermissionsUpdate, + ) -> Result; + #[post( "/api/w/{workspace_id}/subjektiv/subjects", status = 201, @@ -10434,6 +10511,10 @@ pub struct CreateWorkspaceWorkerRequest { /// Backend idempotency key used only for authenticated Worker-owned spawn/control. #[serde(default)] pub control_operation_id: Option, + /// Requested least-authority child permissions. Host derives the origin; this + /// input cannot assert Subject identity or create a body/singleton attachment. + #[serde(default)] + pub execution_grant: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -12935,6 +13016,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }) .unwrap(); assert_eq!( @@ -12949,6 +13031,7 @@ mod tests { "workdir_attachments": [], "feature_connections": {}, "control_operation_id": null, + "execution_grant": null, }) ); } @@ -14538,6 +14621,7 @@ mod openapi_artifact_tests { // identity, or a one-use Reviewer capability. They remain generated Rust client/Axum // operations, but must not appear as unauthenticated operations in the public OpenAPI. const SIGNED_INTERNAL: &[&str] = &[ + "operation_authorize", "current_worker_workspace_config_get", "current_worker_workspace_config_attach", "current_worker_workspace_config_observe", @@ -14575,7 +14659,11 @@ mod openapi_artifact_tests { // the Runtime/Worker-source signed operation inventory above. const OTHER_OPENAPI_EXCLUDED: &[&str] = &["worker_session", "worker_session_history"]; const DOCUMENTED_SIGNED_INTERNAL: &[&str] = &["workspace_runtime_config"]; - const BROWSER_ONLY: &[&str] = &["merge_request_selector_repair"]; + const BROWSER_ONLY: &[&str] = &[ + "merge_request_selector_repair", + "subjektiv_subject_permissions_get", + "subjektiv_subject_permissions_set", + ]; let document = canonical_openapi_document().expect("canonical OpenAPI contract must build"); let value: serde_json::Value = diff --git a/crates/subjektiv/src/api.rs b/crates/subjektiv/src/api.rs index e8785e549..4ce172e3d 100644 --- a/crates/subjektiv/src/api.rs +++ b/crates/subjektiv/src/api.rs @@ -1299,6 +1299,14 @@ pub type OperationResult = std::result::Result; pub fn subjektiv_store_error(error: crate::SubjektivError) -> OperationError { match error { + crate::SubjektivError::AuthorizationDenied(reason) => { + OperationError::PermissionDenied(reason) + } + crate::SubjektivError::SubjectPermissionsConflict(_) + | crate::SubjektivError::PermissionOperationConflict(_) + | crate::SubjektivError::WorkerExecutionGrantConflict(_) => { + OperationError::Conflict(error.to_string()) + } crate::SubjektivError::SubjectScopeMismatch { .. } => { OperationError::PermissionDenied(format!("subject_scope_mismatch: {error}")) } diff --git a/crates/subjektiv/src/legacy_migrations.rs b/crates/subjektiv/src/legacy_migrations.rs index 51ebe661f..8a3c45fd0 100644 --- a/crates/subjektiv/src/legacy_migrations.rs +++ b/crates/subjektiv/src/legacy_migrations.rs @@ -929,7 +929,7 @@ mod tests { let (_manager, _workspace, store) = open_store(&root, "workspace-a"); let migrated_subject = store.subject(&subject_id).unwrap().unwrap(); assert!(migrated_subject.behavior_md.is_empty()); - assert!(migrated_subject.behavior_md.is_empty()); + assert!(store.subject_permissions(&subject_id).unwrap().is_none()); let lifecycle_attribution = attribution( &subject_id, "runtime-1", diff --git a/crates/subjektiv/src/lib.rs b/crates/subjektiv/src/lib.rs index 59ae976f5..86ba70a6e 100644 --- a/crates/subjektiv/src/lib.rs +++ b/crates/subjektiv/src/lib.rs @@ -4,6 +4,7 @@ pub mod api; pub mod job; pub mod journal; mod legacy_migrations; +pub mod permissions; mod schema; use legacy_migrations::{ add_candidate_decision_receipts, add_subject_behavior, add_subject_session_attribution, @@ -82,6 +83,16 @@ pub enum SubjektivError { }, #[error("subject `{subject_id}` behavior content changed")] SubjectBehaviorConflict { subject_id: String }, + #[error("subject `{0}` permission source changed")] + SubjectPermissionsConflict(String), + #[error("permission operation `{0}` was reused with different input")] + PermissionOperationConflict(String), + #[error("authorization denied: {0}")] + AuthorizationDenied(String), + #[error("Worker execution grant binding conflicts for `{0}`")] + WorkerExecutionGrantConflict(String), + #[error("Subject `{subject_id}` has more than {limit} retained Worker execution grants")] + WorkerExecutionGrantListOverflow { subject_id: String, limit: usize }, #[error("surface generation conflict: {0}")] SurfaceGenerationConflict(String), #[error("memory `{memory_id}` cannot transition from {from:?} to {to:?}")] @@ -623,6 +634,11 @@ static MIGRATIONS: &[FeatureMigration] = &[ "add immutable candidate Journal context links", journal::memory_links::migrate, ), + FeatureMigration::new( + 12, + "add explicit Subject permission sources and immutable child execution grants", + permissions::migrate, + ), ]; pub const REGISTRATION: FeatureRegistration = diff --git a/crates/subjektiv/src/permissions.rs b/crates/subjektiv/src/permissions.rs new file mode 100644 index 000000000..06e9df5a3 --- /dev/null +++ b/crates/subjektiv/src/permissions.rs @@ -0,0 +1,288 @@ +//! Host-administered permission sources and immutable child grants, separate from behavior. +//! +//! The Host authenticates administrators before source writes and binds verified Worker/Profile +//! identities before grant writes. These records are not a Worker registry. The Host alone +//! authenticates root execution ownership against its canonical Backend singleton and checks +//! every live lineage edge; Journal bindings and retained grants cannot establish ownership. +//! Replays return historical evidence without restoring a source or granting current authority. +use super::*; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, + WorkerExecutionGrant, validate_authorization_identifier, validate_scoped_permissions, +}; +use sha2::{Digest, Sha256}; + +pub const MAX_SUBJECT_WORKER_EXECUTION_GRANTS: usize = 1_000; + +pub(super) fn migrate(tx: &Transaction<'_>) -> feature_storage::Result<()> { + tx.execute_batch(include_str!("permissions.sql"))?; + Ok(()) +} + +impl SubjektivStore { + /// Unset sources are `None` (deny), including migrated Subjects. This is an admin read, + /// not an effective-authorization decision; retired Subjects retain their source evidence. + pub fn subject_permissions(&self, subject_id: &str) -> Result> { + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + source_in_connection(connection, subject_id) + }) + } + + /// Host-admin-only replacement. Every replacement issues a fresh change ID, including + /// identical contents. Revocation is an explicit empty replacement. A scope-wide operation + /// ID binds Subject, CAS input, and both permission vectors; result and receipt commit together. + pub fn set_subject_permissions( + &self, + subject_id: &str, + update: &SubjectPermissionsUpdate, + ) -> Result { + validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(&update.operation_id) + .map_err(SubjektivError::InvalidRecord)?; + if let Some(change) = &update.expected_change_id { + validate_authorization_identifier(change).map_err(SubjektivError::InvalidRecord)?; + } + validate_scope(self.scope_id(), &update.execution)?; + validate_scope(self.scope_id(), &update.delegation)?; + let fingerprint: String = Sha256::digest(serde_json::to_vec(&(subject_id, update))?) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect(); + self.database.try_transaction(|tx| { + require_subject(tx, subject_id)?; + let receipt: Option<(String, String)> = tx.query_row( + "SELECT request_fingerprint, change_id FROM subject_permission_receipts WHERE operation_id=?1", + [&update.operation_id], |row| Ok((row.get(0)?, row.get(1)?)), + ).optional()?; + if let Some((saved, change_id)) = receipt { + if saved != fingerprint { + return Err(SubjektivError::PermissionOperationConflict(update.operation_id.clone())); + } + let raw: String = tx.query_row( + "SELECT record_json FROM subject_permission_changes WHERE subject_id=?1 AND change_id=?2", + params![subject_id, change_id], |row| row.get(0), + )?; + return Ok(serde_json::from_str(&raw)?); + } + require_active_subject(tx, subject_id)?; + let current = source_in_connection(tx, subject_id)?; + if current.as_ref().map(|source| &source.change_id) != update.expected_change_id.as_ref() { + return Err(SubjektivError::SubjectPermissionsConflict(subject_id.into())); + } + let source = SubjectPermissions { + change_id: issued_id("permissions"), + execution: update.execution.clone(), + delegation: update.delegation.clone(), + }; + tx.execute("INSERT INTO subject_permission_changes(subject_id,change_id,record_json) VALUES(?1,?2,?3)", + params![subject_id, source.change_id, serde_json::to_string(&source)?])?; + tx.execute("INSERT INTO subject_permissions(subject_id,change_id) VALUES(?1,?2) ON CONFLICT(subject_id) DO UPDATE SET change_id=excluded.change_id", + params![subject_id, source.change_id])?; + tx.execute("INSERT INTO subject_permission_receipts(operation_id,subject_id,request_fingerprint,change_id) VALUES(?1,?2,?3,?4)", + params![update.operation_id, subject_id, fingerprint, source.change_id])?; + Ok(source) + }) + } + + /// Binds child grants only. Exact retries recover an immutable binding even after revocation; + /// that success is not authorization to execute or redelegate. New bindings require the + /// current source, an active Subject and explicit delegation containment. The Host must + /// authenticate root singleton ownership and every live lineage edge before calling; + /// a missing retained parent is not ownership evidence. Profile resolution, provider scope, + /// Worker creation and caller authentication also remain Host duties. + pub fn bind_worker_execution_grant(&self, grant: &WorkerExecutionGrant) -> Result<()> { + validate_grant(self.scope_id(), grant)?; + self.database.try_transaction(|tx| { + if let Some(existing) = grant_in_connection(tx, &grant.runtime_id, &grant.worker_id)? { + return if existing == *grant { Ok(()) } else { + Err(SubjektivError::WorkerExecutionGrantConflict(format!("{}/{}", grant.runtime_id, grant.worker_id))) + }; + } + require_active_subject(tx, &grant.subject_id)?; + let source = source_in_connection(tx, &grant.subject_id)? + .ok_or_else(|| denied("Subject permissions are unset"))?; + if source.change_id != grant.source_change_id { + return Err(denied("Subject permission source is no longer current")); + } + require_covered(&source.delegation, &grant.permissions)?; + require_covered(&source.delegation, &grant.redelegation)?; + // A child grant cannot turn a body (including a released historical body) into a + // delegated child or attach a Worker historically attributed to a different Subject. + let invalid_target: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)", + params![grant.runtime_id, grant.worker_id, grant.subject_id], |row| row.get(0), + )?; + if invalid_target { return Err(denied("target is a body or belongs to another Subject")); } + let origin = require_retained_origin(tx, grant)?; + if let Some(origin) = origin { + require_covered(&origin.redelegation, &grant.permissions)?; + require_covered(&origin.redelegation, &grant.redelegation)?; + } + tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + params![grant.runtime_id, grant.worker_id, grant.subject_id, grant.source_change_id, serde_json::to_string(grant)?])?; + Ok(()) + }) + } + + /// Returns this Subject's retained grants, including stale grants needed for cancellation + /// and reporting. Never an ownership list or effective-authorization decision. Overflow is + /// explicit rather than silently omitting Workers from a cancellation/reporting pass. + pub fn list_subject_worker_execution_grants( + &self, + subject_id: &str, + ) -> Result> { + validate_authorization_identifier(subject_id).map_err(SubjektivError::InvalidRecord)?; + self.database.try_with_connection(|connection| { + require_subject_in_connection(connection, subject_id)?; + let mut statement = connection.prepare( + "SELECT record_json FROM worker_execution_grants WHERE subject_id=?1 ORDER BY runtime_id,worker_id LIMIT ?2", + )?; + let mut rows = statement.query(params![subject_id, MAX_SUBJECT_WORKER_EXECUTION_GRANTS + 1])?; + let mut grants = Vec::new(); + while let Some(row) = rows.next()? { + if grants.len() == MAX_SUBJECT_WORKER_EXECUTION_GRANTS { + return Err(SubjektivError::WorkerExecutionGrantListOverflow { + subject_id: subject_id.into(), + limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS, + }); + } + grants.push(serde_json::from_str(&row.get::<_, String>(0)?)?); + } + Ok(grants) + }) + } + + /// Returns retained binding evidence, not current authorization. The Host must compare the + /// active Subject, source change, current origin ownership, Profile and provider constraints. + pub fn worker_execution_grant( + &self, + runtime: &str, + worker: &str, + ) -> Result> { + validate_authorization_identifier(runtime).map_err(SubjektivError::InvalidRecord)?; + validate_authorization_identifier(worker).map_err(SubjektivError::InvalidRecord)?; + self.database + .try_with_connection(|connection| grant_in_connection(connection, runtime, worker)) + } +} + +fn source_in_connection( + connection: &Connection, + subject: &str, +) -> Result> { + let raw: Option = connection.query_row( + "SELECT c.record_json FROM subject_permissions p JOIN subject_permission_changes c ON c.subject_id=p.subject_id AND c.change_id=p.change_id WHERE p.subject_id=?1", + [subject], |row| row.get(0), + ).optional()?; + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) + .transpose() +} + +fn grant_in_connection( + connection: &Connection, + runtime: &str, + worker: &str, +) -> Result> { + let raw: Option = connection + .query_row( + "SELECT record_json FROM worker_execution_grants WHERE runtime_id=?1 AND worker_id=?2", + params![runtime, worker], + |row| row.get(0), + ) + .optional()?; + raw.map(|raw| serde_json::from_str(&raw).map_err(Into::into)) + .transpose() +} + +fn validate_scope(scope: &str, permissions: &[ScopedPermission]) -> Result<()> { + validate_scoped_permissions(permissions).map_err(SubjektivError::InvalidRecord)?; + for permission in permissions { + if let PermissionResource::Workspace { workspace_id } = &permission.resource + && workspace_id != scope + { + return Err(denied("Workspace permission is outside this store scope")); + } + } + Ok(()) +} + +fn validate_grant(scope: &str, grant: &WorkerExecutionGrant) -> Result<()> { + for value in [ + &grant.subject_id, + &grant.origin_runtime_id, + &grant.origin_worker_id, + &grant.source_change_id, + &grant.runtime_id, + &grant.worker_id, + &grant.profile, + &grant.profile_digest, + ] { + validate_authorization_identifier(value).map_err(SubjektivError::InvalidRecord)?; + } + if grant.origin_runtime_id == grant.runtime_id && grant.origin_worker_id == grant.worker_id { + return Err(denied("a child grant cannot originate from itself")); + } + validate_scope(scope, &grant.permissions)?; + validate_scope(scope, &grant.redelegation) +} + +fn require_covered(ceiling: &[ScopedPermission], requested: &[ScopedPermission]) -> Result<()> { + if requested + .iter() + .all(|permission| ceiling.iter().any(|allowed| allowed.covers(permission))) + { + Ok(()) + } else { + Err(denied( + "requested permission or redelegation exceeds its explicit ceiling", + )) + } +} + +fn require_retained_origin( + tx: &Transaction<'_>, + grant: &WorkerExecutionGrant, +) -> Result> { + let mut runtime = grant.origin_runtime_id.clone(); + let mut worker = grant.origin_worker_id.clone(); + let mut direct_origin = None; + let mut visited = HashSet::from([(grant.runtime_id.clone(), grant.worker_id.clone())]); + // Validate retained lineage only. Absence terminates at a Host-authenticated root; + // neither Journal active state nor absence of a grant proves current singleton ownership. + for _ in 0..64 { + if !visited.insert((runtime.clone(), worker.clone())) { + return Err(denied("cyclic grant origin")); + } + let foreign_attribution: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM journal_writer_bindings WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3) OR EXISTS(SELECT 1 FROM subject_session_attributions WHERE runtime_id=?1 AND worker_id=?2 AND subject_id<>?3)", + params![runtime, worker, grant.subject_id], |row| row.get(0), + )?; + if foreign_attribution { + return Err(denied("grant origin is attributed to another Subject")); + } + let Some(origin) = grant_in_connection(tx, &runtime, &worker)? else { + return Ok(direct_origin); + }; + if origin.subject_id != grant.subject_id + || origin.source_change_id != grant.source_change_id + { + return Err(denied( + "grant origin belongs to another Subject or permission source", + )); + } + if direct_origin.is_none() { + direct_origin = Some(origin.clone()); + } + runtime = origin.origin_runtime_id; + worker = origin.origin_worker_id; + } + Err(denied("grant origin exceeds maximum delegation depth")) +} + +fn denied(reason: &str) -> SubjektivError { + SubjektivError::AuthorizationDenied(reason.into()) +} + +#[cfg(test)] +mod tests; diff --git a/crates/subjektiv/src/permissions.sql b/crates/subjektiv/src/permissions.sql new file mode 100644 index 000000000..a663f192a --- /dev/null +++ b/crates/subjektiv/src/permissions.sql @@ -0,0 +1,45 @@ +CREATE TABLE subject_permission_changes ( + subject_id TEXT NOT NULL REFERENCES subjects(subject_id) ON DELETE RESTRICT, + change_id TEXT NOT NULL CHECK (length(change_id) > 0), + record_json TEXT NOT NULL, + PRIMARY KEY (subject_id, change_id) +); +CREATE TABLE subject_permissions ( + subject_id TEXT PRIMARY KEY REFERENCES subjects(subject_id) ON DELETE RESTRICT, + change_id TEXT NOT NULL, + FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TABLE subject_permission_receipts ( + operation_id TEXT PRIMARY KEY CHECK (length(operation_id) > 0), + subject_id TEXT NOT NULL, + request_fingerprint TEXT NOT NULL, + change_id TEXT NOT NULL, + FOREIGN KEY (subject_id, change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TABLE worker_execution_grants ( + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + subject_id TEXT NOT NULL, + source_change_id TEXT NOT NULL, + record_json TEXT NOT NULL, + PRIMARY KEY (runtime_id, worker_id), + FOREIGN KEY (subject_id, source_change_id) REFERENCES subject_permission_changes(subject_id, change_id) ON DELETE RESTRICT +); +CREATE TRIGGER subject_permission_changes_no_update BEFORE UPDATE ON subject_permission_changes BEGIN + SELECT RAISE(ABORT, 'Subject permission changes are immutable'); +END; +CREATE TRIGGER subject_permission_changes_no_delete BEFORE DELETE ON subject_permission_changes BEGIN + SELECT RAISE(ABORT, 'Subject permission changes are retained'); +END; +CREATE TRIGGER subject_permission_receipts_no_update BEFORE UPDATE ON subject_permission_receipts BEGIN + SELECT RAISE(ABORT, 'Subject permission receipts are immutable'); +END; +CREATE TRIGGER subject_permission_receipts_no_delete BEFORE DELETE ON subject_permission_receipts BEGIN + SELECT RAISE(ABORT, 'Subject permission receipts are retained'); +END; +CREATE TRIGGER worker_execution_grants_no_update BEFORE UPDATE ON worker_execution_grants BEGIN + SELECT RAISE(ABORT, 'Worker execution grants are immutable'); +END; +CREATE TRIGGER worker_execution_grants_no_delete BEFORE DELETE ON worker_execution_grants BEGIN + SELECT RAISE(ABORT, 'Worker execution grants are retained'); +END; diff --git a/crates/subjektiv/src/permissions/tests.rs b/crates/subjektiv/src/permissions/tests.rs new file mode 100644 index 000000000..ab426bf2a --- /dev/null +++ b/crates/subjektiv/src/permissions/tests.rs @@ -0,0 +1,656 @@ +//! Real SQLite component tests through store APIs; Host authentication, canonical singleton +//! ownership and live lineage checks are outside this suite. Root origins are Host-attested inputs; +//! Journal fixtures exercise defensive attribution only, never execution authority. +use super::*; +use crate::tests::{open_store, role}; +use feature_storage::FeatureStorage; +use std::path::Path; + +fn permission(operation: &str, path: &str, recursive: bool) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "wd".into(), + path: path.into(), + recursive, + }, + } +} + +fn update( + operation: &str, + expected: Option<&str>, + delegation: Vec, +) -> SubjectPermissionsUpdate { + SubjectPermissionsUpdate { + operation_id: operation.into(), + expected_change_id: expected.map(Into::into), + execution: Vec::new(), + delegation, + } +} + +fn grant(subject: &str, source: &str, worker: &str) -> WorkerExecutionGrant { + WorkerExecutionGrant { + subject_id: subject.into(), + origin_runtime_id: "runtime".into(), + origin_worker_id: "body".into(), + source_change_id: source.into(), + runtime_id: "runtime".into(), + worker_id: worker.into(), + profile: "builtin:coder".into(), + profile_digest: "resolved-profile-sha256".into(), + permissions: vec![permission("file.read", "src", false)], + redelegation: Vec::new(), + } +} + +fn schema(store: &SubjektivStore) -> Vec<(String, String)> { + store.database.try_with_connection::<_, SubjektivError>(|connection| { + let mut statement = connection.prepare("SELECT name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' AND sql IS NOT NULL ORDER BY name")?; + Ok(statement.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?.collect::>()?) + }).unwrap() +} + +fn open_version( + root: &Path, + version: usize, +) -> (FeatureStorage, ScopedFeatureStorage, SubjektivStore) { + let manager = FeatureStorage::new(root); + let scope = manager.scope("workspace-a").unwrap(); + let registration = scope + .register(FeatureRegistration::new( + SUBJEKTIV_FEATURE_ID, + &MIGRATIONS[..version], + )) + .unwrap(); + let store = SubjektivStore::open(&scope, ®istration).unwrap(); + (manager, scope, store) +} + +#[test] +fn replacements_are_cas_fenced_replayable_and_never_restore_old_permissions() { + let temp = tempfile::tempdir().unwrap(); + let subject; + let first; + let first_update = update("allow", None, vec![permission("file.read", ".", true)]); + { + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + subject = store.create_subject(role()).unwrap().id; + assert!(store.subject_permissions(&subject).unwrap().is_none()); + first = store + .set_subject_permissions(&subject, &first_update) + .unwrap(); + assert_eq!( + store + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); + assert!(matches!( + store.set_subject_permissions(&subject, &update("stale", None, Vec::new())), + Err(SubjektivError::SubjectPermissionsConflict(_)) + )); + assert!(matches!( + store.set_subject_permissions(&subject, &update("allow", None, Vec::new())), + Err(SubjektivError::PermissionOperationConflict(_)) + )); + let other = store.create_subject(role()).unwrap(); + assert!(matches!( + store.set_subject_permissions(&other.id, &first_update), + Err(SubjektivError::PermissionOperationConflict(_)) + )); + let same = store + .set_subject_permissions( + &subject, + &update("same", Some(&first.change_id), first.delegation.clone()), + ) + .unwrap(); + assert_ne!(same.change_id, first.change_id); + let revoked = store + .set_subject_permissions( + &subject, + &update("revoke", Some(&same.change_id), Vec::new()), + ) + .unwrap(); + assert!(revoked.delegation.is_empty()); + assert_eq!( + store + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); + assert_eq!(store.subject_permissions(&subject).unwrap(), Some(revoked)); + } + let (_reopened_manager, _reopened_scope, reopened) = open_store(temp.path(), "workspace-a"); + assert!( + reopened + .subject_permissions(&subject) + .unwrap() + .unwrap() + .delegation + .is_empty() + ); + assert_eq!( + reopened + .set_subject_permissions(&subject, &first_update) + .unwrap(), + first + ); +} + +#[test] +fn failed_receipt_insert_rolls_back_source_history_and_projection() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let first = store + .set_subject_permissions(&subject, &update("first", None, Vec::new())) + .unwrap(); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TRIGGER fail_permission_receipt BEFORE INSERT ON subject_permission_receipts BEGIN SELECT RAISE(ABORT,'injected receipt failure'); END;")?; + Ok(()) + }).unwrap(); + let replacement = update( + "second", + Some(&first.change_id), + vec![permission("file.read", ".", true)], + ); + assert!( + store + .set_subject_permissions(&subject, &replacement) + .is_err() + ); + assert_eq!(store.subject_permissions(&subject).unwrap(), Some(first)); + store.database.try_transaction::<_, SubjektivError>(|tx| { + let counts: (u32, u32) = tx.query_row("SELECT (SELECT count(*) FROM subject_permission_changes),(SELECT count(*) FROM subject_permission_receipts)", [], |r| Ok((r.get(0)?, r.get(1)?)))?; + assert_eq!(counts, (1, 1)); + tx.execute_batch("DROP TRIGGER fail_permission_receipt")?; + Ok(()) + }).unwrap(); + assert!( + store + .set_subject_permissions(&subject, &replacement) + .is_ok() + ); +} + +#[test] +fn scope_and_validation_rejections_do_not_issue_permission_sources() { + let temp = tempfile::tempdir().unwrap(); + let (_a_manager, _a_scope, a) = open_store(&temp.path().join("a"), "workspace-a"); + let (_b_manager, _b_scope, b) = open_store(&temp.path().join("b"), "workspace-b"); + let subject = a.create_subject(role()).unwrap().id; + assert!( + b.set_subject_permissions(&subject, &update("cross-subject", None, Vec::new())) + .is_err() + ); + let cross_workspace = ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "workspace-b".into(), + }, + }; + assert!( + a.set_subject_permissions( + &subject, + &update("cross-workspace", None, vec![cross_workspace]) + ) + .is_err() + ); + for (operation, path) in [ + ("file.*", "src"), + ("file..read", "src"), + ("file.read", "../src"), + ("file.read", "src/../escape"), + ] { + assert!( + a.set_subject_permissions( + &subject, + &update("invalid", None, vec![permission(operation, path, true)]) + ) + .is_err() + ); + } + assert!(a.subject_permissions(&subject).unwrap().is_none()); +} + +#[test] +fn child_grants_are_immutable_bounded_and_do_not_create_subject_ownership() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", "src", true)]), + ) + .unwrap(); + let child = grant(&subject, &source.change_id, "child"); + store.bind_worker_execution_grant(&child).unwrap(); + store.bind_worker_execution_grant(&child).unwrap(); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child.clone()) + ); + assert!( + store + .journal_body_is_unpublished(&subject, "runtime", "child") + .unwrap() + ); + let mut changed = child.clone(); + changed.profile_digest = "changed-profile".into(); + assert!(matches!( + store.bind_worker_execution_grant(&changed), + Err(SubjektivError::WorkerExecutionGrantConflict(_)) + )); + for (worker, requested) in [ + ("write", permission("file.write", "src", false)), + ("escape", permission("file.read", ".", true)), + ("prefix", permission("file.read", "src-other", false)), + ] { + let mut candidate = grant(&subject, &source.change_id, worker); + candidate.permissions = vec![requested]; + assert!(store.bind_worker_execution_grant(&candidate).is_err()); + assert!( + store + .worker_execution_grant("runtime", worker) + .unwrap() + .is_none() + ); + } + let mut self_origin = child.clone(); + self_origin.worker_id = "body".into(); + assert!(store.bind_worker_execution_grant(&self_origin).is_err()); + let other = store.create_subject(role()).unwrap(); + store + .claim_journal_writer(&other.id, "runtime", "other-body") + .unwrap(); + let mut invalid = grant(&subject, &source.change_id, "other-body"); + assert!(store.bind_worker_execution_grant(&invalid).is_err()); + invalid.worker_id = "forged-child".into(); + invalid.origin_worker_id = "other-body".into(); + assert!(store.bind_worker_execution_grant(&invalid).is_err()); +} + +#[test] +fn redelegation_requires_current_source_and_explicit_retained_parent_ceiling() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let mut child = grant(&subject, &source.change_id, "child"); + child.redelegation = vec![permission("file.read", "src", true)]; + store.bind_worker_execution_grant(&child).unwrap(); + let mut descendant = grant(&subject, &source.change_id, "grandchild"); + descendant.origin_worker_id = "child".into(); + store.bind_worker_execution_grant(&descendant).unwrap(); + let mut excessive = descendant.clone(); + excessive.worker_id = "excessive".into(); + excessive.redelegation = vec![permission("file.read", ".", true)]; + assert!(store.bind_worker_execution_grant(&excessive).is_err()); + let mut nondelegator = descendant.clone(); + nondelegator.origin_worker_id = "grandchild".into(); + nondelegator.worker_id = "great-grandchild".into(); + assert!(store.bind_worker_execution_grant(&nondelegator).is_err()); + let mut stale = descendant.clone(); + stale.worker_id = "after-source-replacement".into(); + let revoked = store + .set_subject_permissions( + &subject, + &update("revoke", Some(&source.change_id), Vec::new()), + ) + .unwrap(); + assert_ne!(revoked.change_id, source.change_id); + stale.origin_worker_id = "new-body".into(); + assert!(store.bind_worker_execution_grant(&stale).is_err()); + // Exact retries/readback recover evidence but cannot revive the source or origin. + store.bind_worker_execution_grant(&child).unwrap(); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child) + ); + store.retire_subject(&subject).unwrap(); + stale.source_change_id = revoked.change_id; + assert!(store.bind_worker_execution_grant(&stale).is_err()); +} + +#[test] +fn journal_state_neither_authenticates_roots_nor_bypasses_retained_child_ceilings() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + // The Host has authenticated root ownership outside this store. No Journal record is + // required; changing Journal writer state cannot supply or invalidate that authentication. + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "without-journal")) + .unwrap(); + let journal = store + .claim_journal_writer(&subject, "runtime", "body") + .unwrap(); + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-active-journal")) + .unwrap(); + store.release_journal_writer(&journal).unwrap(); + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "with-released-journal")) + .unwrap(); + // An active Journal binding must never upgrade a child's empty redelegation ceiling. + store + .claim_journal_writer(&subject, "runtime", "without-journal") + .unwrap(); + let mut descendant = grant(&subject, &source.change_id, "descendant"); + descendant.origin_worker_id = "without-journal".into(); + assert!(matches!( + store.bind_worker_execution_grant(&descendant), + Err(SubjektivError::AuthorizationDenied(_)) + )); + assert!( + store + .worker_execution_grant("runtime", "descendant") + .unwrap() + .is_none() + ); +} + +#[test] +fn retained_origins_reject_cycles_foreign_subjects_and_stale_parent_sources() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let mut child = grant(&subject, &source.change_id, "child"); + child.redelegation = source.delegation.clone(); + store.bind_worker_execution_grant(&child).unwrap(); + // Closing a retained chain back through its previously unbound root must not create a cycle. + let mut cycle = grant(&subject, &source.change_id, "body"); + cycle.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&cycle), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let other = store.create_subject(role()).unwrap().id; + let other_source = store + .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .unwrap(); + let mut foreign = grant(&other, &other_source.change_id, "foreign-child"); + foreign.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&foreign), + Err(SubjektivError::AuthorizationDenied(_)) + )); + let current = store + .set_subject_permissions( + &subject, + &update("replace", Some(&source.change_id), source.delegation), + ) + .unwrap(); + let mut stale_parent = grant(&subject, ¤t.change_id, "stale-parent-child"); + stale_parent.origin_worker_id = "child".into(); + assert!(matches!( + store.bind_worker_execution_grant(&stale_parent), + Err(SubjektivError::AuthorizationDenied(_)) + )); +} + +#[test] +fn failed_grant_insert_leaves_no_binding_and_can_retry_same_grant() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let child = grant(&subject, &source.change_id, "child"); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TRIGGER fail_grant BEFORE INSERT ON worker_execution_grants BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; + Ok(()) + }).unwrap(); + assert!(store.bind_worker_execution_grant(&child).is_err()); + assert!( + store + .worker_execution_grant("runtime", "child") + .unwrap() + .is_none() + ); + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("DROP TRIGGER fail_grant")?; + Ok(()) + }) + .unwrap(); + store.bind_worker_execution_grant(&child).unwrap(); + for sql in [ + "UPDATE worker_execution_grants SET source_change_id='forged'", + "DELETE FROM worker_execution_grants", + "UPDATE subject_permission_changes SET record_json='{}'", + "DELETE FROM subject_permission_receipts", + ] { + assert!( + store + .database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch(sql)?; + Ok(()) + }) + .is_err(), + "{sql}" + ); + } + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(child) + ); +} + +#[test] +fn current_schema_migration_preserves_subject_and_matches_new_database() { + let temp = tempfile::tempdir().unwrap(); + let root = temp.path().join("migrated"); + let original; + { + let (_old_manager, _old_scope, old) = open_version(&root, 11); + original = old + .create_subject_with_behavior(role(), "Keep behavior separate".into()) + .unwrap(); + } + let (_migrated_manager, _migrated_scope, migrated) = open_store(&root, "workspace-a"); + assert_eq!( + migrated.subject(&original.id).unwrap(), + Some(original.clone()) + ); + assert!( + migrated + .subject_permissions(&original.id) + .unwrap() + .is_none() + ); + let (_fresh_manager, _fresh_scope, fresh) = + open_store(&temp.path().join("fresh"), "workspace-a"); + assert_eq!(schema(&migrated), schema(&fresh)); + let source = migrated + .set_subject_permissions(&original.id, &update("new-source", None, Vec::new())) + .unwrap(); + assert!(!source.change_id.is_empty()); + assert_eq!(migrated.subject(&original.id).unwrap(), Some(original)); +} + +#[test] +fn unset_or_execution_only_source_cannot_issue_child_grants_and_saved_grants_survive_reopen() { + let temp = tempfile::tempdir().unwrap(); + let saved; + { + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + assert!( + store + .bind_worker_execution_grant(&grant(&subject, "unset", "child")) + .is_err() + ); + let mut execution_only = update("execution", None, Vec::new()); + execution_only.execution = vec![permission("file.read", ".", true)]; + let source = store + .set_subject_permissions(&subject, &execution_only) + .unwrap(); + assert!( + store + .bind_worker_execution_grant(&grant(&subject, &source.change_id, "child")) + .is_err() + ); + let source = store + .set_subject_permissions( + &subject, + &update( + "delegation", + Some(&source.change_id), + execution_only.execution, + ), + ) + .unwrap(); + saved = grant(&subject, &source.change_id, "child"); + store.bind_worker_execution_grant(&saved).unwrap(); + } + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + assert_eq!( + store.worker_execution_grant("runtime", "child").unwrap(), + Some(saved.clone()) + ); + store.bind_worker_execution_grant(&saved).unwrap(); + let mut conflicting = saved; + conflicting.profile = "builtin:default".into(); + assert!(matches!( + store.bind_worker_execution_grant(&conflicting), + Err(SubjektivError::WorkerExecutionGrantConflict(_)) + )); +} + +#[test] +fn subject_grant_listing_is_scoped_retained_ordered_and_fails_on_overflow() { + let temp = tempfile::tempdir().unwrap(); + let (_manager, _scope, store) = open_store(temp.path(), "workspace-a"); + let subject = store.create_subject(role()).unwrap().id; + let other = store.create_subject(role()).unwrap().id; + assert!( + store + .list_subject_worker_execution_grants(&subject) + .unwrap() + .is_empty() + ); + let source = store + .set_subject_permissions( + &subject, + &update("allow", None, vec![permission("file.read", ".", true)]), + ) + .unwrap(); + let other_source = store + .set_subject_permissions(&other, &update("other", None, source.delegation.clone())) + .unwrap(); + let other_grant = grant(&other, &other_source.change_id, "other-child"); + store.bind_worker_execution_grant(&other_grant).unwrap(); + for index in (0..MAX_SUBJECT_WORKER_EXECUTION_GRANTS).rev() { + store + .bind_worker_execution_grant(&grant( + &subject, + &source.change_id, + &format!("child-{index:04}"), + )) + .unwrap(); + } + store + .set_subject_permissions( + &subject, + &update("revoke", Some(&source.change_id), Vec::new()), + ) + .unwrap(); + store.retire_subject(&subject).unwrap(); + let listed = store + .list_subject_worker_execution_grants(&subject) + .unwrap(); + assert_eq!(listed.len(), MAX_SUBJECT_WORKER_EXECUTION_GRANTS); + assert!( + listed + .iter() + .all(|grant| grant.subject_id == subject && grant.source_change_id == source.change_id) + ); + assert!( + listed + .windows(2) + .all(|pair| (&pair[0].runtime_id, &pair[0].worker_id) + < (&pair[1].runtime_id, &pair[1].worker_id)) + ); + assert_eq!( + store.list_subject_worker_execution_grants(&other).unwrap(), + vec![other_grant.clone()] + ); + // Seed one more retained row directly: the retired Subject cannot issue new grants. + // Listing must report overflow rather than returning a seemingly complete cancellation set. + let overflow = grant(&subject, &source.change_id, "overflow"); + store.database.try_transaction::<_, SubjektivError>(|tx| { + tx.execute("INSERT INTO worker_execution_grants(runtime_id,worker_id,subject_id,source_change_id,record_json) VALUES(?1,?2,?3,?4,?5)", + params![overflow.runtime_id, overflow.worker_id, overflow.subject_id, overflow.source_change_id, serde_json::to_string(&overflow)?])?; + Ok(()) + }).unwrap(); + assert!( + matches!(store.list_subject_worker_execution_grants(&subject), Err(SubjektivError::WorkerExecutionGrantListOverflow { subject_id, limit: MAX_SUBJECT_WORKER_EXECUTION_GRANTS }) if subject_id == subject) + ); + assert_eq!( + store.list_subject_worker_execution_grants(&other).unwrap(), + vec![other_grant] + ); + let (_foreign_manager, _foreign_scope, foreign) = + open_store(&temp.path().join("foreign"), "workspace-b"); + assert!(matches!( + foreign.list_subject_worker_execution_grants(&subject), + Err(SubjektivError::SubjectNotFound(_)) + )); +} + +#[test] +fn failed_schema_migration_rolls_back_new_tables_and_preserves_old_data() { + let temp = tempfile::tempdir().unwrap(); + let original; + { + let (_old_manager, _old_scope, old) = open_version(temp.path(), 11); + original = old.create_subject(role()).unwrap(); + old.database + .try_transaction::<_, SubjektivError>(|tx| { + tx.execute_batch("CREATE TABLE subject_permissions (collision TEXT)")?; + Ok(()) + }) + .unwrap(); + } + { + let manager = FeatureStorage::new(temp.path()); + let scope = manager.scope("workspace-a").unwrap(); + let registration = SubjektivStore::register(&scope).unwrap(); + assert!(SubjektivStore::open(&scope, ®istration).is_err()); + } + let (_old_manager, _old_scope, old) = open_version(temp.path(), 11); + assert_eq!(old.database.schema_version().unwrap(), 11); + assert_eq!(old.subject(&original.id).unwrap(), Some(original)); + old.database.try_with_connection::<_, SubjektivError>(|connection| { + let created: bool = connection.query_row("SELECT EXISTS(SELECT 1 FROM sqlite_master WHERE name='subject_permission_changes')", [], |row| row.get(0))?; + assert!(!created); + Ok(()) + }).unwrap(); +} diff --git a/crates/tui/src/backend_spawn.rs b/crates/tui/src/backend_spawn.rs index fadfd7599..765557434 100644 --- a/crates/tui/src/backend_spawn.rs +++ b/crates/tui/src/backend_spawn.rs @@ -162,6 +162,7 @@ fn request_from_selection(selection: Selection) -> BackendCreateWorkerRequest { ticket_assignment: None, feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, } } diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 74044cde9..3b3a92e65 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -1113,6 +1113,7 @@ fn runtime_local_workdir_router( attachments: &BTreeMap, capabilities: &BTreeMap, command_output_scope: manifest::SharedScope, + authorization_client: Option>, ) -> Result, String> { let router = Arc::new(WorkdirSessionRouter::new()); for (alias, binding) in attachments { @@ -1121,22 +1122,28 @@ fn runtime_local_workdir_router( .copied() .unwrap_or(WorkdirSessionCapabilities::READ_ONLY); let scope = runtime_attachment_scope(binding.root(), capabilities)?; + let source = runtime_local_workdir_session( + &binding.working_directory.id, + binding.root(), + binding.cwd(), + scope, + command_output_scope.clone(), + capabilities, + binding.command_environment(), + binding + .session_resources() + .map_err(|error| format!("open Workdir attachment `{alias}` resources: {error}"))?, + ); + let source = match authorization_client.as_ref() { + Some(client) => worker::authorized_workdir::authorized_workdir_session( + source, + client.clone(), + binding.working_directory.id.clone(), + ), + None => source, + }; router - .attach( - alias.clone(), - runtime_local_workdir_session( - &binding.working_directory.id, - binding.root(), - binding.cwd(), - scope, - command_output_scope.clone(), - capabilities, - binding.command_environment(), - binding.session_resources().map_err(|error| { - format!("open Workdir attachment `{alias}` resources: {error}") - })?, - ), - ) + .attach(alias.clone(), source) .map_err(|error| format!("bind Workdir attachment `{alias}`: {error}"))?; } Ok(router) @@ -1163,7 +1170,12 @@ fn restored_workdir_router( local_capabilities.insert(attachment.alias.clone(), attachment.capabilities); } } - let router = runtime_local_workdir_router(local_attachments, &local_capabilities, scope)?; + let router = runtime_local_workdir_router( + local_attachments, + &local_capabilities, + scope, + Some(workspace_client.clone()), + )?; for attachment in logical_attachments { if let Some(local) = local_attachments.get(&attachment.alias) { if local.working_directory.id != attachment.working_directory_id { @@ -1572,6 +1584,7 @@ impl RuntimeWorkerFactory for ProfileRuntimeWorkerFactory { &request.workdir_attachments, &workdir_capabilities, worker.scope().clone(), + Some(worker.workspace_client_handle()), )?); if let (Some(runtime_id), Some(workspace_id)) = (observation_runtime_id, observation_workspace_id.clone()) @@ -10234,7 +10247,8 @@ mod tests { let alias = WorkdirAttachmentAlias::new("checkout").unwrap(); let attachments = BTreeMap::from([(alias, binding.clone())]); let scope = manifest::SharedScope::new(manifest::Scope::writable(binding.root()).unwrap()); - let router = runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope).unwrap(); + let router = + runtime_local_workdir_router(&attachments, &BTreeMap::new(), scope, None).unwrap(); session_clone.close().await.unwrap(); assert_eq!( materializer.cleanup_working_directory(id).unwrap_err().code, diff --git a/crates/worker/src/authorized_workdir.rs b/crates/worker/src/authorized_workdir.rs new file mode 100644 index 000000000..3da819ff3 --- /dev/null +++ b/crates/worker/src/authorized_workdir.rs @@ -0,0 +1,1129 @@ +//! Live Host authorization for trusted Features using Runtime-local Workdir sessions. +//! +//! Point access uses provider-resolved leases; traversals use provider scope layers. +//! This adapter adds no Tool executor or shell sandbox and retains provider ceilings. + +use std::{future::Future, path::Path, sync::Arc}; + +use async_trait::async_trait; +use server_api::authorization::PermissionResource; +use tokio::sync::{OnceCell, broadcast}; +use workdir::{ + CheckoutObservation, CheckoutOperation, CheckoutRequest, CheckoutResult, + CheckoutSearchOperation, CheckoutSearchRequest, CheckoutSearchResult, CommandEvent, + CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandSnapshot, + CommandStatus, EditRequest, EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, + ListRequest, ListResult, ReadBytesRequest, ReadBytesResult, ReadRequest, ReadResult, + StatRequest, StatResult, Workdir, WorkdirError, WorkdirPath, WorkdirScopeAuthorizationRequest, + WorkdirScopeOverlapRequest, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionHandle, + WorkdirToolBroker, WorkdirToolScope, WorkdirToolScopePermission, WorkdirToolScopeRule, + WriteRequest, WriteResult, +}; + +use crate::WorkspaceClient; + +const READ: &str = "filesystem.read"; +const WRITE: &str = "filesystem.write"; +const COMMAND: &str = "bash.execute"; + +/// Bind the authority-assigned attachment identity, never a local provider ID or alias. +/// Only explicitly standalone clients bypass Host authorization; an unavailable bound +/// transport must return an authorization error instead of becoming standalone. +pub fn authorized_workdir_session( + session: WorkdirSessionHandle, + client: Arc, + working_directory_id: String, +) -> WorkdirSessionHandle { + if !client.is_available() && client.workspace_id().is_none() && client.kind() == "unavailable" { + return session; + } + Arc::new(AuthorizedWorkdirSession { + session, + client, + working_directory_id, + scope_broker: OnceCell::new(), + }) +} + +#[derive(Debug)] +struct AuthorizedWorkdirSession { + session: WorkdirSessionHandle, + client: Arc, + working_directory_id: String, + scope_broker: OnceCell, +} + +impl AuthorizedWorkdirSession { + fn resource( + &self, + path: &WorkdirPath, + recursive: bool, + ) -> Result { + let normalized = WorkdirPath::new(path.as_str()).map_err(|_| { + WorkdirError::Denied("Host authorization requires attachment-relative paths".into()) + })?; + let resource = PermissionResource::Workdir { + working_directory_id: self.working_directory_id.clone(), + path: normalized.to_string(), + recursive, + }; + resource.validate().map_err(|error| { + WorkdirError::Denied(format!("invalid Workdir authorization resource: {error}").into()) + })?; + Ok(resource) + } + + fn authorize( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + ) -> Result<(), WorkdirError> { + self.client + .authorize_operation(operation, &self.resource(path, recursive)?) + .map_err(authorization_error) + } + + fn authorized_scope( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + ) -> Result, WorkdirError> { + let requested = self.resource(path, recursive)?; + let Some(granted) = self + .client + .authorize_operation_scope(operation, &requested) + .map_err(authorization_error)? + else { + // Only the Host may choose the explicit non-Subject legacy contract. + return Ok(None); + }; + if !granted.covers(&requested) { + return Err(WorkdirError::Denied( + "Host returned a scope that does not cover the operation resource".into(), + )); + } + let PermissionResource::Workdir { + path, recursive, .. + } = granted + else { + return Err(WorkdirError::Denied( + "Host returned a non-Workdir scope".into(), + )); + }; + Ok(Some(WorkdirToolScopeRule { + target: WorkdirPath::new(path)?, + recursive, + permission: match operation { + READ => WorkdirToolScopePermission::Read, + WRITE => WorkdirToolScopePermission::Write, + _ => { + return Err(WorkdirError::Denied( + "operation has no filesystem scope mapping".into(), + )); + } + }, + symlink_policy: manifest::SymlinkPolicy::Resolved, + })) + } + + async fn with_authorized( + &self, + operation: &str, + path: &WorkdirPath, + recursive: bool, + effect: F, + ) -> Result + where + F: FnOnce(WorkdirSessionHandle, WorkdirPath, Option) -> Fut, + Fut: Future>, + { + let Some(rule) = self.authorized_scope(operation, path, recursive)? else { + return effect(self.session.clone(), WorkdirPath::root(), None).await; + }; + let broker = self + .scope_broker + .get_or_init(|| async { WorkdirToolBroker::new(self.session.clone()) }) + .await; + // Resolve against the granted prefix, not the requested path: a symlink + // must not turn its target into the root of its own authorization. + let lease = broker + .scope(WorkdirToolScope { + rules: vec![rule.clone()], + cwd: rule.target.clone(), + command: false, + }) + .await?; + let result = effect(lease.tool_session(), rule.target.clone(), Some(rule)).await; + // Close only the attenuated lease. Source hash history, scope, command + // ownership and attachment lifetime survive separate operations. + match (result, lease.close().await) { + (result, Ok(())) => result, + (Ok(_), Err(cleanup)) => Err(WorkdirError::OutcomeUnknown(format!( + "operation completed but its authorization lease cleanup failed: {cleanup}" + ))), + (Err(operation), Err(cleanup)) => Err(WorkdirError::OutcomeUnknown(format!( + "operation failed: {operation}; authorization lease cleanup also failed: {cleanup}" + ))), + } + } + + fn require_legacy(&self, operation: &str) -> Result<(), WorkdirError> { + if self + .authorized_scope(operation, &WorkdirPath::root(), true)? + .is_some() + { + return Err(WorkdirError::Denied( + "operation has no complete scoped resource mapping".into(), + )); + } + Ok(()) + } + + async fn authorized_search( + &self, + operation: CheckoutSearchOperation, + ) -> Result { + let path = operation.path().clone(); + self.with_authorized(READ, &path, true, |scoped, root, rule| async move { + let Some(rule) = rule else { + return match operation { + CheckoutSearchOperation::List(request) => self + .session + .list(request) + .await + .map(CheckoutSearchResult::List), + CheckoutSearchOperation::Glob(request) => self + .session + .glob(request) + .await + .map(CheckoutSearchResult::Glob), + CheckoutSearchOperation::Grep(request) => self + .session + .grep(request) + .await + .map(CheckoutSearchResult::Grep), + }; + }; + scoped + .authorize_scope_path(WorkdirScopeAuthorizationRequest { + rules: vec![WorkdirToolScopeRule { + target: WorkdirPath::root(), + ..rule.clone() + }], + path: relative_path(&root, operation.path())?, + permission: WorkdirToolScopePermission::Read, + }) + .await?; + // Lease cwd rebases paths. Use the same provider scoped-traversal contract + // with attachment output_root instead, retaining typed paths, cursors and + // Grep's rendered filenames without parsing or post-filtering output. + let mut request = CheckoutSearchRequest::new(operation); + request.scope_layers.push(vec![rule]); + self.session.checkout_search(request).await + }) + .await + } + + fn authorize_command(&self) -> Result<(), WorkdirError> { + // cwd is not a sandbox: arbitrary shell can access the entire attachment. + self.authorize(COMMAND, &WorkdirPath::root(), true) + } +} + +fn authorization_error(error: crate::WorkspaceClientError) -> WorkdirError { + WorkdirError::Denied(format!("Workdir Host authorization failed: {error}").into()) +} + +fn relative_path(root: &WorkdirPath, path: &WorkdirPath) -> Result { + let relative = Path::new(path.as_str()) + .strip_prefix(root.as_str()) + .map_err(|_| { + WorkdirError::Denied("operation path exceeds the granted provider scope".into()) + })?; + WorkdirPath::new( + relative + .to_str() + .ok_or_else(|| WorkdirError::InvalidPath("non-UTF8 scoped path".into()))?, + ) + .map_err(WorkdirError::from) +} + +#[async_trait] +impl WorkdirSession for AuthorizedWorkdirSession { + fn workdir(&self) -> &Workdir { + self.session.workdir() + } + + fn capabilities(&self) -> WorkdirSessionCapabilities { + // This is the provider ceiling, not a cached live authorization decision. + self.session.capabilities() + } + + async fn authorize_scope_path( + &self, + mut request: WorkdirScopeAuthorizationRequest, + ) -> Result<(), WorkdirError> { + let path = request.path.clone(); + self.with_authorized( + match request.permission { + WorkdirToolScopePermission::Read => READ, + WorkdirToolScopePermission::Write => WRITE, + }, + &path, + false, + |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + for rule in &mut request.rules { + rule.target = relative_path(&root, &rule.target)?; + } + session.authorize_scope_path(request).await + }, + ) + .await + } + + async fn scope_rules_overlap( + &self, + request: WorkdirScopeOverlapRequest, + ) -> Result { + self.require_legacy(READ)?; + self.session.scope_rules_overlap(request).await + } + + async fn checkout_search( + &self, + request: CheckoutSearchRequest, + ) -> Result { + self.require_legacy(READ)?; + self.session.checkout_search(request).await + } + + async fn checkout_observe( + &self, + path: WorkdirPath, + ) -> Result { + self.require_legacy(READ)?; + self.session.checkout_observe(path).await + } + + async fn checkout_execute( + &self, + request: CheckoutRequest, + ) -> Result { + self.require_legacy(match &request.operation { + CheckoutOperation::Read { .. } | CheckoutOperation::ReadBytes { .. } => READ, + CheckoutOperation::Write { .. } + | CheckoutOperation::Edit { .. } + | CheckoutOperation::Create { .. } => WRITE, + })?; + self.session.checkout_execute(request).await + } + + async fn stat(&self, mut request: StatRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.stat(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn read(&self, mut request: ReadRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.read(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn read_bytes( + &self, + mut request: ReadBytesRequest, + ) -> Result { + let path = request.path.clone(); + self.with_authorized(READ, &path, false, |session, root, _| async move { + let original = request.path.clone(); + request.path = relative_path(&root, &request.path)?; + let mut result = session.read_bytes(request).await?; + result.path = original; + Ok(result) + }) + .await + } + + async fn write(&self, mut request: WriteRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(WRITE, &path, false, |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + session.write(request).await + }) + .await + } + + async fn edit(&self, mut request: EditRequest) -> Result { + let path = request.path.clone(); + self.with_authorized(WRITE, &path, false, |session, root, _| async move { + request.path = relative_path(&root, &request.path)?; + session.edit(request).await + }) + .await + } + + async fn list(&self, request: ListRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::List(request)) + .await? + { + CheckoutSearchResult::List(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped List result".into(), + )), + } + } + + async fn glob(&self, request: GlobRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::Glob(request)) + .await? + { + CheckoutSearchResult::Glob(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped Glob result".into(), + )), + } + } + + async fn grep(&self, request: GrepRequest) -> Result { + match self + .authorized_search(CheckoutSearchOperation::Grep(request)) + .await? + { + CheckoutSearchResult::Grep(result) => Ok(result), + _ => Err(WorkdirError::Unavailable( + "mismatched scoped Grep result".into(), + )), + } + } + + 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 + } + + async fn command_status(&self, handle: CommandHandle) -> Result { + // Reconciliation of an owned effect is not new execution authority. + self.session.command_status(handle).await + } + + async fn command_output( + &self, + request: CommandOutputRequest, + ) -> Result { + self.authorize_command()?; + self.session.command_output(request).await + } + + async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { + self.session.cancel_command(handle).await + } + + fn subscribe_command_events(&self) -> Option> { + let runtime = tokio::runtime::Handle::try_current().ok()?; + let mut source = self.session.subscribe_command_events()?; + let (sender, receiver) = broadcast::channel(64); + let client = self.client.clone(); + let resource = PermissionResource::Workdir { + working_directory_id: self.working_directory_id.clone(), + path: ".".into(), + recursive: true, + }; + // A subscription must not freeze the grant at subscription time. Check + // each new output access; terminal/status events remain usable for cleanup. + runtime.spawn(async move { + loop { + let event = tokio::select! { + _ = sender.closed() => break, + event = source.recv() => match event { + Ok(event) => event, + Err(broadcast::error::RecvError::Lagged(_)) => continue, + Err(broadcast::error::RecvError::Closed) => break, + }, + }; + if matches!(event, CommandEvent::Output { .. }) + && client.authorize_operation(COMMAND, &resource).is_err() + { + continue; + } + if sender.send(event).is_err() { + break; + } + } + }); + Some(receiver) + } + + fn command_snapshot(&self) -> Vec { + if self.authorize_command().is_err() { + return Vec::new(); + } + self.session.command_snapshot() + } + + async fn close(&self) -> Result<(), WorkdirError> { + self.session.close().await + } +} + +#[cfg(test)] +mod tests { + //! Component tests connect this adapter to the real LocalWorkdirSession. + //! Only the Host authorization transport/policy is replaced; these are not E2E tests. + + use super::*; + use crate::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + use server_api::authorization::ScopedPermission; + use std::sync::{ + Mutex, + atomic::{AtomicBool, Ordering}, + }; + use tempfile::TempDir; + use workdir::LocalWorkdirSession; + + #[derive(Debug)] + struct AuthorizationClient { + legacy: bool, + revoked: AtomicBool, + requests: Mutex>, + permissions: Vec, + } + + impl AuthorizationClient { + fn new(permissions: Vec) -> Arc { + Arc::new(Self { + legacy: false, + revoked: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + permissions, + }) + } + } + + impl WorkspaceClient for AuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "mock-bound" + } + fn is_available(&self) -> bool { + true + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("the authorization client must not execute protected effects") + } + fn authorize_operation( + &self, + operation: &str, + resource: &PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.authorize_operation_scope(operation, resource) + .map(|_| ()) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &PermissionResource, + ) -> Result, WorkspaceClientError> { + let request = ScopedPermission { + operation: operation.into(), + resource: resource.clone(), + }; + self.requests.lock().unwrap().push(request.clone()); + if !self.revoked.load(Ordering::SeqCst) { + if self.legacy { + return Ok(None); + } + if let Some(granted) = self + .permissions + .iter() + .find(|permission| permission.covers(&request)) + { + return Ok(Some(granted.resource.clone())); + } + } + Err(WorkspaceClientError::Request( + "denied by current grant".into(), + )) + } + } + + fn permission(operation: &str, path: &str, recursive: bool) -> ScopedPermission { + ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: "attachment-id".into(), + path: path.into(), + recursive, + }, + } + } + + fn session(root: &TempDir, client: Arc) -> WorkdirSessionHandle { + authorized_workdir_session( + Arc::new(LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )), + client, + "attachment-id".into(), + ) + } + + fn read(path: &str) -> ReadRequest { + ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 100, + max_bytes: 4096, + } + } + + fn command(command: &str) -> CommandRequest { + CommandRequest { + command: command.into(), + timeout_secs: 10, + output_limit: 4096, + cwd: WorkdirPath::root(), + spill_dir: None, + tool_call_id: None, + } + } + + #[tokio::test] + async fn writes_check_current_exact_resource_and_denial_has_no_filesystem_effect() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![permission(WRITE, "allowed.txt", false)]); + let session = session(&root, client.clone()); + session + .write(WriteRequest { + path: WorkdirPath::new("./allowed.txt").unwrap(), + content: b"allowed".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("allowed.txt")).unwrap(), + b"allowed" + ); + let denied = session + .write(WriteRequest { + path: WorkdirPath::new("outside/new.txt").unwrap(), + content: b"denied".to_vec(), + expected_hash: None, + }) + .await + .unwrap_err(); + assert!(matches!(denied, WorkdirError::Denied(_))); + assert!(!root.path().join("outside").exists()); + client.revoked.store(true, Ordering::SeqCst); + let denied = session + .write(WriteRequest { + path: WorkdirPath::new("allowed.txt").unwrap(), + content: b"revoked".to_vec(), + expected_hash: None, + }) + .await + .unwrap_err(); + assert!(matches!(denied, WorkdirError::Denied(_))); + assert_eq!( + std::fs::read(root.path().join("allowed.txt")).unwrap(), + b"allowed" + ); + assert_eq!( + *client.requests.lock().unwrap(), + vec![ + permission(WRITE, "allowed.txt", false), + permission(WRITE, "outside/new.txt", false), + permission(WRITE, "allowed.txt", false), + ] + ); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn read_revocation_is_checked_again_on_the_same_local_session() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("note"), "private").unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "note", false)]); + let session = session(&root, client.clone()); + assert_eq!(session.read(read("note")).await.unwrap().bytes, b"private"); + client.revoked.store(true, Ordering::SeqCst); + assert!(matches!( + session.read(read("note")).await, + Err(WorkdirError::Denied(_)) + )); + assert_eq!(client.requests.lock().unwrap().len(), 2); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn narrow_command_grant_does_not_treat_cwd_as_a_sandbox() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + let client = AuthorizationClient::new(vec![permission(COMMAND, "src", true)]); + let session = session(&root, client.clone()); + let mut request = command("printf denied > escaped"); + request.cwd = WorkdirPath::new("src").unwrap(); + assert!(matches!( + session.start_command(request).await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("src/escaped").exists()); + assert_eq!( + *client.requests.lock().unwrap(), + vec![permission(COMMAND, ".", true)] + ); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn revoked_commands_cannot_start_or_read_output_but_status_and_cancel_remain_owned() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![permission(COMMAND, ".", true)]); + let session = session(&root, client.clone()); + let handle = session + .start_command(command("exec tail -f /dev/null")) + .await + .unwrap(); + client.revoked.store(true, Ordering::SeqCst); + assert!(matches!( + session.start_command(command("printf bad > escaped")).await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("escaped").exists()); + assert!(matches!( + session + .command_output(CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit: 4096, + wait: false, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(session.command_snapshot().is_empty()); + let checks_before_cleanup = client.requests.lock().unwrap().len(); + assert_eq!( + session.command_status(handle.clone()).await.unwrap(), + CommandStatus::Running + ); + assert!(matches!( + session + .cancel_command(CommandHandle("not-owned".into())) + .await, + Err(WorkdirError::UnknownCommand(_)) + )); + session.cancel_command(handle.clone()).await.unwrap(); + session.close().await.unwrap(); + assert_eq!(client.requests.lock().unwrap().len(), checks_before_cleanup); + } + + #[tokio::test] + async fn authorization_success_does_not_expand_provider_scope() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![permission(WRITE, ".", true)]); + let local = LocalWorkdirSession::new( + manifest::Scope::from_config(&manifest::ScopeConfig { + allow: vec![manifest::ScopeRule { + target: root.path().to_path_buf(), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }], + deny: Vec::new(), + }) + .unwrap(), + root.path().to_path_buf(), + ); + let session = + authorized_workdir_session(Arc::new(local), client.clone(), "attachment-id".into()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("file").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await + .is_err() + ); + assert_eq!( + std::fs::read(root.path().join("file")).unwrap(), + b"original" + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn unmappable_absolute_or_spill_paths_are_denied_before_local_effects() { + let root = tempfile::tempdir().unwrap(); + let client = AuthorizationClient::new(vec![ + permission(WRITE, ".", true), + permission(COMMAND, ".", true), + ]); + let session = session(&root, client.clone()); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new_scoped(root.path().join("absolute").to_string_lossy()) + .unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .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(); + } + + #[tokio::test] + async fn scoped_unmapped_checkout_and_overlap_are_denied_even_with_root_grants() { + let root = tempfile::tempdir().unwrap(); + std::fs::write(root.path().join("file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, ".", true), + permission(WRITE, ".", true), + ]); + let session = session(&root, client); + assert!(matches!( + session.checkout_observe(WorkdirPath::root()).await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: WorkdirPath::new("file").unwrap(), + validator: vec![1], + operation: CheckoutOperation::Write { + content: b"bad".to_vec(), + expected_hash: [0; 32] + }, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_execute(CheckoutRequest { + target: WorkdirPath::root(), + validator: vec![1], + operation: CheckoutOperation::Create { + path: WorkdirPath::new("nested/new").unwrap(), + content: b"bad".to_vec() + }, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(matches!( + session + .checkout_search(CheckoutSearchRequest::new(CheckoutSearchOperation::List( + ListRequest { + path: WorkdirPath::root(), + after: None, + limit: 10, + } + ))) + .await, + Err(WorkdirError::Denied(_)) + )); + let rule = WorkdirToolScopeRule { + target: WorkdirPath::root(), + permission: WorkdirToolScopePermission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }; + assert!(matches!( + session + .scope_rules_overlap(WorkdirScopeOverlapRequest { + left: rule.clone(), + right: rule + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert_eq!( + std::fs::read(root.path().join("file")).unwrap(), + b"original" + ); + assert!(!root.path().join("nested").exists()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn explicit_legacy_authorization_forwards_checkout_operations() { + let root = tempfile::tempdir().unwrap(); + let client = Arc::new(AuthorizationClient { + legacy: true, + revoked: AtomicBool::new(false), + requests: Mutex::new(Vec::new()), + permissions: Vec::new(), + }); + let session = session(&root, client.clone()); + let parent = session.checkout_observe(WorkdirPath::root()).await.unwrap(); + session + .checkout_execute(CheckoutRequest { + target: parent.path, + validator: parent.validator, + operation: CheckoutOperation::Create { + path: WorkdirPath::new("nested/new").unwrap(), + content: b"legacy".to_vec(), + }, + }) + .await + .unwrap(); + assert_eq!( + session.read(read("nested/new")).await.unwrap().bytes, + b"legacy" + ); + assert_eq!(client.requests.lock().unwrap().len(), 3); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn scoped_read_then_write_keeps_provider_hash_state_and_attachment_paths() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::write(root.path().join("src/file"), "original").unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, "src", true), + permission(WRITE, "src", true), + ]); + let session = session(&root, client); + let original = session.read(read("src/file")).await.unwrap(); + assert_eq!(original.path.as_str(), "src/file"); + session + .write(WriteRequest { + path: WorkdirPath::new("src/file").unwrap(), + content: b"replacement".to_vec(), + expected_hash: Some(original.content_hash), + }) + .await + .unwrap(); + let replacement = session.read(read("src/file")).await.unwrap(); + assert_eq!(replacement.bytes, b"replacement"); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/file").unwrap(), + content: b"stale".to_vec(), + expected_hash: Some(original.content_hash), + }) + .await, + Err(WorkdirError::Conflict(_)) + )); + session + .edit(EditRequest { + path: WorkdirPath::new("src/file").unwrap(), + old_string: "replacement".into(), + new_string: "edited".into(), + replace_all: false, + expected_hash: replacement.content_hash, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("src/file")).unwrap(), + b"edited" + ); + session.close().await.unwrap(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn resolved_subtree_grants_reject_symlink_read_and_write_escape() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::write(root.path().join("private/secret"), "private marker").unwrap(); + std::os::unix::fs::symlink("../private", root.path().join("src/link")).unwrap(); + let client = AuthorizationClient::new(vec![ + permission(READ, "src", true), + permission(WRITE, "src", true), + ]); + let session = session(&root, client); + assert!(session.read(read("src/link/secret")).await.is_err()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/link/new").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await + .is_err() + ); + assert!(!root.path().join("private/new").exists()); + assert_eq!( + std::fs::read(root.path().join("private/secret")).unwrap(), + b"private marker" + ); + session.close().await.unwrap(); + } + + #[cfg(target_os = "linux")] + #[tokio::test] + async fn provider_scoped_recursive_traversal_does_not_leak_symlink_targets() { + let root = tempfile::tempdir().unwrap(); + std::fs::create_dir(root.path().join("src")).unwrap(); + std::fs::create_dir(root.path().join("private")).unwrap(); + std::fs::write(root.path().join("src/public.txt"), "needle public marker").unwrap(); + std::fs::write( + root.path().join("private/secret.txt"), + "needle private marker", + ) + .unwrap(); + std::os::unix::fs::symlink("../private", root.path().join("src/link")).unwrap(); + std::os::unix::fs::symlink("../private/secret.txt", root.path().join("src/alias.txt")) + .unwrap(); + let client = AuthorizationClient::new(vec![permission(READ, "src", true)]); + let session = session(&root, client); + let result = session + .glob(GlobRequest { + path: WorkdirPath::new("src").unwrap(), + pattern: "**/*.txt".into(), + limit: 100, + }) + .await + .unwrap(); + assert_eq!( + result.paths, + vec![WorkdirPath::new("src/public.txt").unwrap()] + ); + for output_mode in [ + workdir::GrepOutputMode::Content, + workdir::GrepOutputMode::FilesWithMatches, + workdir::GrepOutputMode::Count, + ] { + let result = session + .grep(GrepRequest { + path: WorkdirPath::new("src").unwrap(), + pattern: "needle".into(), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode, + limit: 100, + offset: 0, + }) + .await + .unwrap(); + assert_eq!( + result.paths, + vec![WorkdirPath::new("src/public.txt").unwrap()] + ); + assert!(!result.output.contains("private")); + assert!(!result.output.contains("secret")); + assert!(!result.output.contains("alias.txt")); + assert!(result.output.contains("src/public.txt")); + assert_eq!(result.matched_files, 1); + } + // List may reject a symlinked entry instead of skipping it. Either behavior + // must avoid disclosing target metadata; successful entries stay scoped. + if let Ok(listed) = session + .list(ListRequest { + path: WorkdirPath::new("src").unwrap(), + after: None, + limit: 100, + }) + .await + { + assert!( + listed + .entries + .iter() + .all(|entry| !entry.path.as_str().contains("alias.txt") + && !entry.path.as_str().contains("link")) + ); + } + session.close().await.unwrap(); + } + + #[tokio::test] + async fn unavailable_workspace_bound_client_does_not_fall_back_to_local_authority() { + let root = tempfile::tempdir().unwrap(); + let workspace_id = crate::WorkspaceId::new("workspace").unwrap(); + let session = session( + &root, + crate::unavailable_workspace_client(Some(&workspace_id), "transport unavailable"), + ); + assert!(matches!( + session + .write(WriteRequest { + path: WorkdirPath::new("denied").unwrap(), + content: b"bad".to_vec(), + expected_hash: None, + }) + .await, + Err(WorkdirError::Denied(_)) + )); + assert!(!root.path().join("denied").exists()); + session.close().await.unwrap(); + } + + #[tokio::test] + async fn standalone_unavailable_client_preserves_local_compatibility() { + let root = tempfile::tempdir().unwrap(); + let session = session( + &root, + crate::unavailable_workspace_client(None, "standalone test"), + ); + session + .write(WriteRequest { + path: WorkdirPath::new("local").unwrap(), + content: b"standalone".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + std::fs::read(root.path().join("local")).unwrap(), + b"standalone" + ); + session.close().await.unwrap(); + } +} diff --git a/crates/worker/src/bootstrap.rs b/crates/worker/src/bootstrap.rs index d027aa8c9..7fc1fbe4c 100644 --- a/crates/worker/src/bootstrap.rs +++ b/crates/worker/src/bootstrap.rs @@ -145,6 +145,13 @@ where pub async fn prepare( self, ) -> Result, St>, WorkerBootstrapError> { + crate::feature::authorize_requested_manifest( + self.workspace_context.client_handle().as_ref(), + &self.manifest, + ) + .map_err(|error| { + WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string())) + })?; let mut worker = Worker::from_manifest_with_context_and_model_client( self.manifest, self.store, @@ -197,6 +204,13 @@ where .await .map_err(WorkerBootstrapError::Worker)?; + crate::feature::authorize_requested_manifest( + worker.workspace_client_handle().as_ref(), + worker.manifest(), + ) + .map_err(|error| { + WorkerBootstrapError::Worker(WorkerError::FeatureLifecycle(error.to_string())) + })?; if let Some(workdir_session) = self.workdir_session { worker.bind_single_workdir_session(Some(workdir_session)); } diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 994a8f665..1c03115c4 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1453,6 +1453,14 @@ where // Worker-immutable snapshots taken before the mutable worker borrow // below so the worker borrow doesn't conflict with reads on `worker`. let feature_config = worker.manifest().feature.clone(); + // First boundary: before any Feature initialization, Tool/WIP/service + // registration, prompt contribution, or discovery side effect. Host derives + // the live caller grant; the Profile only requests these switches. + crate::feature::authorize_requested_manifest( + worker.workspace_client_handle().as_ref(), + worker.manifest(), + ) + .map_err(|error| std::io::Error::new(std::io::ErrorKind::PermissionDenied, error))?; let workdir_sessions = worker.workdir_sessions(); let restrict_workdir_tools_to_current_capabilities = inherited_workdir_tool_broker.is_some(); let workdir_tool_broker = Some( @@ -4139,6 +4147,13 @@ mod tests { request: crate::worker::WorkspaceRequest, ) -> Result { + if request.path == "/api/w/workspace-1/authorization/check" { + assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Post); + return Ok(crate::worker::WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":null}).to_string(), + }); + } // Native discovery reads metadata and the current caller's ceiling, never inventory or mutations. assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Get); if request.path == "/api/w/workspace-1/drive/capabilities" { @@ -4217,7 +4232,7 @@ mod tests { worker.workdir_sessions(), ) .descriptor(); - register_worker_tools( + let registered = register_worker_tools( &mut worker, root.path().join("bash-output"), root.path().join("runtime"), @@ -4225,8 +4240,16 @@ mod tests { None, None, ) - .await - .unwrap(); + .await; + if enabled && (!available || workspace.is_none()) { + assert_eq!( + registered.unwrap_err().kind(), + std::io::ErrorKind::PermissionDenied + ); + assert!(requests.lock().unwrap().is_empty()); + continue; + } + registered.unwrap(); let configured = enabled && available && workspace.is_some(); let tools = worker.engine().tool_server_handle(); tools.flush_pending(); diff --git a/crates/worker/src/feature.rs b/crates/worker/src/feature.rs index 3d3ea8983..6c5bf2fdd 100644 --- a/crates/worker/src/feature.rs +++ b/crates/worker/src/feature.rs @@ -1751,6 +1751,59 @@ pub enum FeaturePlanError { /// Builder/installer for enabled feature modules. pub struct FeatureRegistryBuilder { modules: Vec>, + activation_guard: Option Result<(), String> + Send + Sync>>, +} + +/// Profile switches request activation, not authority. Names are the actual +/// public config keys (not internal module ids such as `builtin:sub-worker-control`). +/// Check even switches whose runtime prerequisites will later make them inert. +/// No parent/child permission inheritance is performed here. +pub(crate) fn authorize_requested_features( + client: &dyn crate::worker::WorkspaceClient, + config: &manifest::FeatureConfig, +) -> Result<(), crate::worker::WorkspaceClientError> { + for (name, enabled) in [ + ("task", config.task.enabled), + ("memory", config.memory.enabled()), + ("subjektiv", config.subjektiv.enabled()), + ("web", config.web.enabled), + ("image", config.image.enabled), + ("sub_worker", config.sub_worker.enabled), + ("flow", config.flow.enabled), + ("worker", config.worker.enabled), + ( + "workspace_worker_discovery", + config.workspace_worker_discovery.enabled, + ), + ("objective", config.objective.enabled), + ("manage_workdir", config.manage_workdir.enabled), + ("workdir_catalog", config.workdir_catalog.enabled), + ("workspace_config", config.workspace_config.enabled), + ("drive", config.drive.enabled), + ("ticket", config.ticket.enabled), + ("merge_request", config.merge_request.any()), + ("orchestration", config.orchestration.enabled), + ] { + if enabled { + client.authorize_feature_activation(name).map_err(|error| { + crate::worker::WorkspaceClientError::Request(format!( + "Feature `{name}` activation rejected: {error}" + )) + })?; + } + } + Ok(()) +} + +pub(crate) fn authorize_requested_manifest( + client: &dyn crate::worker::WorkspaceClient, + manifest: &manifest::WorkerManifest, +) -> Result<(), crate::worker::WorkspaceClientError> { + authorize_requested_features(client, &manifest.feature)?; + if !manifest.mcp.stdio_servers.is_empty() { + client.authorize_feature_activation("mcp")?; + } + Ok(()) } fn build_feature_install_plan( @@ -1945,6 +1998,7 @@ impl Default for FeatureRegistryBuilder { fn default() -> Self { Self { modules: Vec::new(), + activation_guard: None, } } } @@ -1954,6 +2008,22 @@ impl FeatureRegistryBuilder { Self::default() } + /// Add a trusted, live preflight that runs before *any* module install. Worker + /// hosts use this in addition to the earlier pre-construction preflight. + pub fn with_activation_guard( + mut self, + guard: impl Fn() -> Result<(), String> + Send + Sync + 'static, + ) -> Self { + let previous = self.activation_guard.take(); + self.activation_guard = Some(Arc::new(move || { + if let Some(previous) = &previous { + previous()?; + } + guard() + })); + self + } + pub fn add_module(&mut self, module: M) -> &mut Self where M: FeatureModule + 'static, @@ -2025,6 +2095,42 @@ impl FeatureRegistryBuilder { hook_builder: &mut HookRegistryBuilder, mut installed_tool_names: HashMap, ) -> FeatureRegistryInstallReport { + if let Some(error) = self + .activation_guard + .as_ref() + .and_then(|guard| guard().err()) + { + return FeatureRegistryInstallReport { + reports: { + let mut descriptors = self.descriptors(); + if descriptors.is_empty() { + descriptors.push(FeatureDescriptor::builtin( + "activation", + "Feature activation", + )); + } + descriptors + .iter() + .map(|descriptor| { + let mut report = FeatureInstallReport::new(descriptor); + report + .diagnostics + .push(FeatureDiagnostic::error(error.clone())); + report.mark_skipped( + FeatureContributionKind::Diagnostic, + descriptor.id.to_string(), + "Feature activation rejected before installation", + ); + report + }) + .collect() + }, + services: FeatureServiceRegistry::default(), + background_tasks: FeatureBackgroundTaskRegistry::default(), + chat_invocations: FeatureInvocationRegistry::default(), + plan_error: None, + }; + } let descriptors: Vec<_> = self .modules .iter() @@ -2300,6 +2406,170 @@ mod tests { use serde_json::json; use std::sync::atomic::{AtomicUsize, Ordering}; + #[derive(Debug)] + struct ActivationClient { + allowed: Vec, + requested: std::sync::Mutex>, + } + + impl crate::worker::WorkspaceClient for ActivationClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "activation-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: crate::worker::WorkspaceRequest, + ) -> Result { + use server_api::authorization::{OperationAuthorizationRequest, PermissionResource}; + assert_eq!(request.path, "/api/w/workspace/authorization/check"); + let input: OperationAuthorizationRequest = + serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert_eq!(input.resource, PermissionResource::Feature); + self.requested.lock().unwrap().push(input.operation.clone()); + Ok(crate::worker::WorkspaceResponse { + status: if self.allowed.contains(&input.operation) { + 200 + } else { + 403 + }, + body: "{}".into(), + }) + } + } + + #[test] + fn requested_activation_uses_exact_public_config_names_before_install() { + let mut config = manifest::FeatureConfig::default(); + config.workdir_catalog.enabled = false; + // Enabling each config key independently also detects default-on flags. + for name in [ + "task", + "memory", + "subjektiv", + "web", + "image", + "sub_worker", + "flow", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "workspace_config", + "drive", + "ticket", + "merge_request", + "orchestration", + ] { + let mut value = serde_json::to_value(&config).unwrap(); + if name == "memory" || name == "subjektiv" { + value[name]["profile"]["enabled"] = json!(true); + } else if name == "merge_request" { + value[name]["show"] = json!(true); + } else { + value[name]["enabled"] = json!(true); + } + let requested: manifest::FeatureConfig = serde_json::from_value(value).unwrap(); + let client = ActivationClient { + allowed: vec![name.into()], + requested: Default::default(), + }; + authorize_requested_features(&client, &requested).unwrap(); + assert_eq!(*client.requested.lock().unwrap(), [name], "{name}"); + } + } + + #[test] + fn ticket_operation_permission_does_not_enable_feature_or_install_services() { + let mut config = manifest::FeatureConfig::default(); + config.workdir_catalog.enabled = false; + config.ticket.enabled = true; + for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] { + let client = Arc::new(ActivationClient { + allowed: allowed.iter().map(|name| name.to_string()).collect(), + requested: Default::default(), + }); + let module = PlannedServiceFeature::new( + FeatureDescriptor::builtin("ticket", "Ticket") + .with_provided_service(provided_service("ticket.test", "1")), + ); + let calls = module.install_calls.clone(); + let config = config.clone(); + let registry = FeatureRegistryBuilder::new() + .with_module(module) + .with_activation_guard(move || { + authorize_requested_features(client.as_ref(), &config) + .map_err(|error| error.to_string()) + }); + let mut tools = Vec::new(); + let report = registry.install_into_pending(&mut tools, &mut HookRegistryBuilder::new()); + let permitted = allowed == ["ticket"]; + assert_eq!(report.has_errors(), !permitted); + assert_eq!(calls.load(Ordering::SeqCst), usize::from(permitted)); + assert!(tools.is_empty()); + if !permitted { + assert!(report.error_message().contains("ticket")); + } + } + } + + #[test] + fn local_compatibility_is_explicit_and_never_grants_workspace_operations() { + use crate::worker::{WorkspaceClient, unavailable_workspace_client}; + use server_api::authorization::PermissionResource; + let local = unavailable_workspace_client(None, "standalone"); + assert!(local.authorize_feature_activation("task").is_ok()); + assert!( + local + .authorize_operation( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "workspace".into() + } + ) + .is_err() + ); + let unbound = ActivationClient { + allowed: vec!["ticket".into()], + requested: Default::default(), + }; + assert!(unbound.authorize_feature_activation("ticket").is_ok()); + let local_files = crate::worker::WorkerWorkspaceContext::local_filesystem(None); + assert!( + local_files + .client_handle() + .authorize_feature_activation("task") + .is_ok() + ); + assert!( + local_files + .client_handle() + .authorize_operation( + "ticket.create", + &PermissionResource::Workspace { + workspace_id: "workspace".into() + } + ) + .is_err() + ); + let marker = crate::worker::marker_workspace_client(None, "local-filesystem"); + assert!( + marker.authorize_feature_activation("task").is_err(), + "display kind is not authority" + ); + let missing = unavailable_workspace_client( + Some(&crate::worker::WorkspaceId::new("workspace").unwrap()), + "Host unavailable", + ); + assert!(missing.authorize_feature_activation("ticket").is_err()); + } + #[test] fn worker_feature_composition_has_no_dynamic_plugin_install_path() { let feature_source = include_str!("feature.rs") diff --git a/crates/worker/src/feature/builtin/drive/delegation.rs b/crates/worker/src/feature/builtin/drive/delegation.rs index 0162833c2..a0cb8640a 100644 --- a/crates/worker/src/feature/builtin/drive/delegation.rs +++ b/crates/worker/src/feature/builtin/drive/delegation.rs @@ -102,6 +102,26 @@ impl WorkspaceClient for InternalChildWorkspaceClient { self.inner.is_available() } + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.inner.authorize_operation(operation, resource) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + self.inner.authorize_operation_scope(operation, resource) + } + + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.inner.authorize_feature_activation(feature) + } + fn execute( &self, request: WorkspaceRequest, diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 8e3f3607a..cab1c2a0d 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -6,7 +6,6 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta use async_trait::async_trait; use schemars::JsonSchema; use serde::Deserialize; -use uuid::Uuid; use protocol::Segment; use server_api::{ @@ -502,7 +501,10 @@ pub struct WorkerLifecycleSpawnRequest { pub profile: String, pub singleton_key: Option, pub ticket_id: Option, + /// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input. pub operation_id: Option, + /// Explicit requested authority. Profile selection alone grants nothing. + pub execution_grant: Option, pub display_name: String, pub initial_submit: Vec, } @@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService { fn workspace_worker_create_request( request: WorkerLifecycleSpawnRequest, ) -> Result { - let ticket_assignment = match (request.ticket_id, request.operation_id) { - (Some(ticket_id), Some(operation_id)) => { - Some(CreateWorkspaceWorkerTicketAssignmentRequest { + let control_operation_id = request.operation_id.filter(|id| { + !id.trim().is_empty() && id.len() <= 256 && !id.chars().any(char::is_control) + }).ok_or_else(|| WorkspaceClientError::Request( + "operation_id is required for every Worker spawn (reuse the same ID and full input after an unknown outcome)".into() + ))?; + if request.ticket_id.is_none() && request.orchestrator_assignment_id.is_some() { + return Err(WorkspaceClientError::Request( + "orchestrator_assignment_id requires ticket_id".into(), + )); + } + if let Some(grant) = &request.execution_grant { + grant.validate().map_err(WorkspaceClientError::Request)?; + } + let ticket_assignment = + request + .ticket_id + .map(|ticket_id| CreateWorkspaceWorkerTicketAssignmentRequest { orchestrator_assignment_id: request.orchestrator_assignment_id, ticket_id, - operation_id, - }) - } - (None, None) => None, - _ => { - return Err(WorkspaceClientError::Request( - "ticket_id and operation_id must be provided together".to_string(), - )); - } - }; - let control_operation_id = ticket_assignment - .as_ref() - .map(|assignment| assignment.operation_id.clone()) - .unwrap_or_else(|| format!("worker-spawn-{}", Uuid::now_v7())); + operation_id: control_operation_id.clone(), + }); Ok(CreateWorkspaceWorkerRequest { runtime_id: request.runtime_id, display_name: request.display_name, @@ -551,6 +555,7 @@ fn workspace_worker_create_request( .collect(), feature_connections: Default::default(), control_operation_id: Some(control_operation_id), + execution_grant: request.execution_grant, }) } @@ -744,7 +749,13 @@ struct WorkerSpawnInput { runtime_id: String, /// Singular Workdir selection wrapped into the shared attachment collection /// under the stable `workdir` alias. - working_directory_id: String, + #[serde(default)] + working_directory_id: Option, + /// Stable caller-chosen intent ID. Exact retries retain this ID and full input. + operation_id: String, + /// Explicit permission/resource request; selecting a Profile grants nothing. + #[serde(default)] + execution_grant: Option, profile: String, /// Optional opaque Workspace singleton key. Prefixes are caller-owned and uninterpreted. #[serde(default)] @@ -931,13 +942,23 @@ impl Tool for WorkspaceWorkerTool { .ticket_id .map(|ticket_id| authority_id(&ticket_id, "ticket_id")) .transpose()?; - let operation_id = ticket_id - .as_ref() - .map(|ticket_id| { - let call_id = non_empty(ctx.call_id.clone(), "tool call_id")?; - Ok::<_, ToolError>(format!("worker-spawn:{ticket_id}:{call_id}")) - }) - .transpose()?; + let operation_id = Some(authority_id(&input.operation_id, "operation_id")?); + let workdir_attachments = match input.working_directory_id { + Some(id) => vec![WorkerLifecycleWorkdirAttachment { + alias: INITIAL_WORKDIR_ALIAS.to_string(), + working_directory_id: authority_id(&id, "working_directory_id")?, + relative_cwd: input + .relative_cwd + .map(|value| validate_relative_cwd(&value)) + .transpose()?, + }], + None if input.relative_cwd.is_none() => Vec::new(), + None => { + return Err(ToolError::ExecutionFailed( + "relative_cwd requires working_directory_id".into(), + )); + } + }; self.control .spawn_worker(WorkerLifecycleSpawnRequest { orchestrator_assignment_id: input @@ -945,17 +966,8 @@ impl Tool for WorkspaceWorkerTool { .map(|id| authority_id(&id, "orchestrator_assignment_id")) .transpose()?, runtime_id: authority_id(&input.runtime_id, "runtime_id")?, - workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { - alias: INITIAL_WORKDIR_ALIAS.to_string(), - working_directory_id: authority_id( - &input.working_directory_id, - "working_directory_id", - )?, - relative_cwd: input - .relative_cwd - .map(|value| validate_relative_cwd(&value)) - .transpose()?, - }], + workdir_attachments, + execution_grant: input.execution_grant, profile: non_empty(input.profile, "profile")?, singleton_key: input.singleton_key, ticket_id, @@ -1594,6 +1606,7 @@ mod tests { "relative_cwd": "crates/worker", "profile": "builtin:coder", "ticket_id": "00001KZ9E0DBS", + "operation_id": "stable-ticket-intent", "initial_submit": [ { "kind": "flow", "selector": "builtin:coder-review" }, { "kind": "text", "content": "Implement Ticket 00001" } @@ -1623,7 +1636,7 @@ mod tests { body["ticket_assignment"], serde_json::json!({ "ticket_id": "00001KZ9E0DBS", - "operation_id": "worker-spawn:00001KZ9E0DBS:call-1" + "operation_id": "stable-ticket-intent" }) ); assert_eq!(body["workdir_attachments"].as_array().unwrap().len(), 1); @@ -1910,10 +1923,80 @@ mod tests { ); } + #[tokio::test] + async fn ordinary_spawn_requires_stable_id_and_forwards_grant_without_ticket_or_workdir() { + let client = Arc::new(RecordingWorkspaceClient::default()); + let tool = WorkspaceWorkerTool { + operation: WorkerOperation::Spawn, + control: test_control(client.clone()), + runtime_worker_control: true, + }; + let mut input = serde_json::json!({"runtime_id":"runtime-1", "profile":"builtin:coder"}); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); + input["operation_id"] = serde_json::json!("ordinary-intent-1"); + input["execution_grant"] = serde_json::json!({"permissions":[{"operation":"ticket", "resource":{"kind":"feature"}}], "redelegation":[]}); + for call_id in ["call-1", "retry-another-call"] { + tool.execute( + &input.to_string(), + ToolExecutionContext::new(call_id, "batch", 0), + ) + .await + .unwrap(); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].body, requests[1].body); + let body: serde_json::Value = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["control_operation_id"], "ordinary-intent-1"); + assert_eq!(body["execution_grant"], input["execution_grant"]); + assert_eq!(body["workdir_attachments"], serde_json::json!([])); + assert!( + body.get("ticket_assignment") + .is_none_or(|value| value.is_null()) + ); + } + + #[test] + fn lifecycle_spawn_rejects_missing_or_blank_id_before_backend() { + for operation_id in [ + None, + Some("".into()), + Some(" ".into()), + Some("bad\nidentity".into()), + ] { + let result = workspace_worker_create_request(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: None, + runtime_id: "runtime-1".into(), + workdir_attachments: Vec::new(), + profile: "builtin:coder".into(), + singleton_key: None, + ticket_id: None, + operation_id, + execution_grant: None, + display_name: "Worker".into(), + initial_submit: Vec::new(), + }); + assert!(result.unwrap_err().to_string().contains("operation_id")); + } + } + #[test] fn worker_spawn_schema_exposes_normal_typed_segment_variants() { let schema = serde_json::to_value(schemars::schema_for!(WorkerSpawnInput)).unwrap(); let text = serde_json::to_string(&schema).unwrap(); + assert!( + schema["required"] + .as_array() + .unwrap() + .contains(&serde_json::json!("operation_id")) + ); + assert!(text.contains("execution_grant")); assert!(text.contains("initial_submit")); assert!(text.contains("ticket_id")); assert!(text.contains("selector")); @@ -1941,7 +2024,8 @@ mod tests { profile: "builtin:coder".to_string(), singleton_key: Some("subjektiv:subject-1".to_string()), ticket_id: None, - operation_id: None, + operation_id: Some("ordinary-spawn-intent-1".into()), + execution_grant: None, display_name: "Coder".to_string(), initial_submit: vec![ Segment::Flow { @@ -1991,6 +2075,7 @@ mod tests { singleton_key: None, ticket_id: Some("T-719".to_string()), operation_id: Some("spawn-ticket-worker:T-719:call-1".to_string()), + execution_grant: None, display_name: "Worker · T-719".to_string(), initial_submit: vec![Segment::text("Investigate Ticket T-719.")], }) diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index 495c382cf..c08a783ac 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -147,6 +147,7 @@ impl Tool for SpawnTicketWorkerTool { operation_id: Some(format!("spawn-ticket-worker:{}:{call_id}", ticket.id)), display_name: format!("Worker · {}", ticket.resource_key), initial_submit, + execution_grant: None, }) .await .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 81c9eecbe..704c5744d 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1"; pub trait TicketService: Send + Sync { fn ticket_handoff(&self, ticket_ref: &str) -> Result; + + /// Typed creation without a synthetic Tool context. The default keeps legacy + /// handoff-only fixture implementations fail-closed; production installs + /// provide creation through the shared authorized backend dispatch. + fn create(&self, _input: NewTicket) -> TicketResult { + Err(TicketError::Conflict( + "Ticket creation service is unavailable".into(), + )) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -526,6 +535,7 @@ fn is_canonical_ticket_resource_key(resource_key: &str) -> bool { struct WorkspaceTicketService { backend: WorkspaceHttpTicketBackend, + access: TicketFeatureAccess, } fn ticket_handoff_from_record(ticket: Ticket) -> Result { @@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService { fn ticket_handoff(&self, ticket_ref: &str) -> Result { ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?) } + + fn create(&self, input: NewTicket) -> TicketResult { + // Services retain the same Profile ceiling as Tools and WIP. Host + // permission cannot turn a read-only Feature surface into an author. + if !self.access.authoring { + return Err(TicketError::Conflict( + "Ticket creation is disabled by the Profile authoring policy".into(), + )); + } + self.backend.create(input) + } } fn ticket_workflow_instruction() -> FeatureInstructionDeclaration { @@ -801,8 +822,17 @@ impl FeatureModule for TicketFeature { fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> { let workspace_client = self.workspace_client(); + // Direct registry installs do not necessarily have a matching manifest + // switch. The public module identity still requires live activation + // authority before any Tool, instruction or service contribution. + workspace_client + .authorize_feature_activation(FEATURE_ID) + .map_err(|error| { + FeatureInstallError::Install(format!("Ticket activation rejected: {error}")) + })?; let ticket_service: Arc = Arc::new(WorkspaceTicketService { backend: WorkspaceHttpTicketBackend::new(workspace_client.clone()), + access: self.access, }); context.services().provide( ServiceDeclaration::new( @@ -1104,6 +1134,22 @@ impl WorkspaceHttpTicketBackend { "Tickets must be created open; closing requires a case judgment".into(), )); } + // Shared trusted boundary for Tool, WIP and Ticket service callers. + // Activation (`ticket`) is not `ticket.create`. Other Ticket operations + // are not yet adapted to this seam; missing checks there are not + // supplied automatically by a universal Tool executor. + client + .authorize_operation( + "ticket.create", + &server_api::authorization::PermissionResource::Workspace { + workspace_id: workspace_id.clone(), + }, + ) + .map_err(|error| { + TicketError::Conflict(format!( + "Ticket create authorization failed: {error}" + )) + })?; let ticket = Self::request( client, WorkspaceRequestMethod::Post, @@ -3541,9 +3587,13 @@ mod tests { } fn workspace_feature(access: TicketFeatureAccess) -> TicketFeature { - let client: Arc = Arc::new( - crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"), - ); + let client: Arc = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed: true, + backend_status: 200, + requests: Default::default(), + }); ticket_tools_feature(client, access) } @@ -4075,6 +4125,7 @@ mod tests { }); let service = WorkspaceTicketService { + access: TicketFeatureAccess::read_only(), backend: WorkspaceHttpTicketBackend::new(Arc::new( crate::worker::TestWorkspaceHttpClient::new("workspace-a", base_url), )), @@ -4095,11 +4146,322 @@ mod tests { } } + // This suite keeps the real Feature/Tool/WIP/backend-dispatch code; only the + // Host transport is replaced. Host grant/storage behavior is tested at Server. + #[derive(Debug)] + struct CreateAuthorizationClient { + workspace: Option<&'static str>, + activation_allowed: bool, + allowed: bool, + backend_status: u16, + requests: Mutex>, + } + + impl WorkspaceClient for CreateAuthorizationClient { + fn workspace_id(&self) -> Option<&str> { + self.workspace + } + fn kind(&self) -> &str { + "create-authorization-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let authorization = request.path.ends_with("/authorization/check"); + let mut permitted = self.allowed; + if authorization { + assert_eq!(request.method, WorkspaceRequestMethod::Post); + let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + if body["operation"] == "ticket" { + assert_eq!( + body, + json!({"operation":"ticket", "resource":{"kind":"feature"}}) + ); + permitted = self.activation_allowed; + } else { + assert_eq!( + body, + json!({"operation":"ticket.create", "resource":{"kind":"workspace","workspace_id":"workspace"}}) + ); + } + } else { + assert_eq!(request.path, "/api/w/workspace/tickets"); + } + self.requests.lock().unwrap().push(request); + Ok(crate::worker::WorkspaceResponse { + status: if authorization { + if permitted { 200 } else { 403 } + } else { + self.backend_status + }, + body: if authorization { + if permitted { + "{}".into() + } else { + json!({"message":"Ticket permission revoked"}).to_string() + } + } else if self.backend_status != 200 { + json!({"error":"Forbidden", "message":"backend create denied", "diagnostics":[]}).to_string() + } else { + serde_json::to_string(&TicketRef { + id: "created-1".into(), + resource_key: Some("T-1".into()), + slug: "created".into(), + status: ticket::TicketStatus::Open, + }) + .unwrap() + }, + }) + } + } + + #[tokio::test] + async fn create_authorization_guards_tool_wip_installed_service_and_backend_without_local_root() + { + for (allowed, backend_status) in [(false, 200), (true, 200), (true, 403)] { + for surface in ["tool", "wip", "service", "backend"] { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed, + backend_status, + requests: Default::default(), + }); + let result = match surface { + "backend" => WorkspaceHttpTicketBackend::new(client.clone()) + .create(NewTicket::new("Create")) + .map(|_| ()) + .map_err(|error| error.to_string()), + "service" => { + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + )) + .install_into_pending( + &mut Vec::new(), + &mut HookRegistryBuilder::default(), + ); + assert!(!report.has_errors(), "{}", report.error_message()); + let service = report + .services + .service::(&ServiceId::builtin(TICKET_SERVICE_ID)) + .unwrap(); + service + .create(NewTicket::new("Create")) + .map(|_| ()) + .map_err(|error| error.to_string()) + } + "tool" => { + let tools = enabled_ticket_definitions( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + ); + let (_, tool) = tools + .into_iter() + .find(|definition| definition().0.name == "TicketCreate") + .unwrap()(); + tool.execute(r#"{"title":"Create"}"#, ToolExecutionContext::direct()) + .await + .map(|_| ()) + .map_err(|error| error.to_string()) + } + _ => { + let tools = native_ticket_tools( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + ) + .unwrap(); + let handler = TicketCollectionWipHandler { + tools: operation_map(tools), + permissions: None, + collection_route: "/tickets".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + handler + .call( + "create", + &BTreeMap::from([( + "title".into(), + WipValue::String("Create".into()), + )]), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "test".into(), + }, + ) + .await + .map(|_| ()) + .map_err(|error| match error { + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + message, + .. + }) => message, + _ => panic!("a definitive denial is not outcome-unknown"), + }) + } + }; + assert_eq!( + result.is_ok(), + allowed && backend_status == 200, + "{surface}: {result:?}" + ); + if allowed && backend_status == 403 { + let error = result.unwrap_err(); + assert!(error.contains("HTTP status 403"), "{surface}: {error}"); + assert!( + error.contains("backend create denied"), + "{surface}: {error}" + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 1 + usize::from(allowed) + usize::from(surface == "service"), + "{surface}" + ); + assert!(requests[0].path.ends_with("/authorization/check")); + assert_eq!( + requests + .iter() + .filter(|request| request.path.ends_with("/tickets")) + .count(), + usize::from(allowed) + ); + } + } + } + + #[test] + fn direct_ticket_install_requires_activation_even_without_manifest_switch() { + assert!(!manifest::FeatureConfig::default().ticket.enabled); + for create_allowed in [false, true] { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: false, + allowed: create_allowed, + backend_status: 200, + requests: Default::default(), + }); + let mut tools = Vec::new(); + // No manifest-config guard: this is the explicit module install path. + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::workspace_authoring(), + )) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(report.has_errors()); + assert!( + report + .error_message() + .contains("Ticket activation rejected") + ); + assert!(report.error_message().contains("HTTP status 403")); + assert!(tools.is_empty()); + assert!(report.installed_instruction_contributions().is_empty()); + assert!(report.services.providers().is_empty()); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } + } + + #[test] + fn installed_ticket_service_readonly_profile_cannot_create_with_host_permission() { + let client = Arc::new(CreateAuthorizationClient { + workspace: Some("workspace"), + activation_allowed: true, + allowed: true, + backend_status: 200, + requests: Default::default(), + }); + let mut tools = Vec::new(); + let report = FeatureRegistryBuilder::new() + .with_module(ticket_tools_feature( + client.clone(), + TicketFeatureAccess::read_only(), + )) + .install_into_pending(&mut tools, &mut HookRegistryBuilder::default()); + assert!(!report.has_errors(), "{}", report.error_message()); + assert!(!tools.iter().any(|tool| tool().0.name == "TicketCreate")); + let service = report + .services + .service::(&ServiceId::builtin(TICKET_SERVICE_ID)) + .unwrap(); + assert!( + service + .create(NewTicket::new("No profile bypass")) + .unwrap_err() + .to_string() + .contains("Profile authoring policy") + ); + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests.len(), + 1, + "only activation; no create check or backend effect" + ); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["operation"], "ticket"); + } + + #[test] + fn legacy_handoff_only_service_does_not_gain_creation() { + struct LegacyService; + impl TicketService for LegacyService { + fn ticket_handoff(&self, _: &str) -> Result { + unreachable!("creation must not call handoff") + } + } + assert!( + LegacyService + .create(NewTicket::new("No implicit creation")) + .unwrap_err() + .to_string() + .contains("unavailable") + ); + } + + #[test] + fn create_without_workspace_identity_fails_before_transport_and_backend_denial_is_preserved() { + for (workspace, allowed, backend_status, expected_calls) in + [(None, true, 200, 0), (Some("workspace"), true, 403, 2)] + { + let client = Arc::new(CreateAuthorizationClient { + workspace, + activation_allowed: true, + allowed, + backend_status, + requests: Default::default(), + }); + let result = + WorkspaceHttpTicketBackend::new(client.clone()).create(NewTicket::new("Create")); + assert!(result.is_err()); + assert_eq!(client.requests.lock().unwrap().len(), expected_calls); + } + } + #[test] fn workspace_http_backend_executes_ticket_create_operation() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); let server = thread::spawn(move || { + let (mut authorization, _) = listener.accept().unwrap(); + authorization + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut buffer = [0_u8; 8192]; + let len = authorization.read(&mut buffer).unwrap(); + let request = String::from_utf8_lossy(&buffer[..len]); + assert!(request.starts_with("POST /api/w/workspace-a/authorization/check HTTP/1.1")); + assert!(request.contains("\"operation\":\"ticket.create\"")); + assert!(request.contains("\"workspace_id\":\"workspace-a\"")); + write!(authorization, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 2\r\nConnection: close\r\n\r\n{{}}").unwrap(); + drop(authorization); let (mut stream, _) = listener.accept().unwrap(); let mut buffer = [0_u8; 8192]; let len = stream.read(&mut buffer).unwrap(); diff --git a/crates/worker/src/lib.rs b/crates/worker/src/lib.rs index 10cf5a6c5..402728e21 100644 --- a/crates/worker/src/lib.rs +++ b/crates/worker/src/lib.rs @@ -1,5 +1,6 @@ #![doc = include_str!("../README.md")] +pub mod authorized_workdir; mod backend_job; pub use backend_job::BackendJobExecutionBinding; pub mod bootstrap; diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index cfdc3f6b3..17415fbc6 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -326,9 +326,32 @@ mod tests { use crate::feature::builtin::merge_request::MergeRequestFeature; use crate::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; use crate::hook::HookRegistryBuilder; - use crate::worker::TestWorkspaceHttpClient; + use crate::worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceResponse, + }; use manifest::MergeRequestFeatureConfig; + #[derive(Debug)] + struct MetadataOnlyHost; + impl WorkspaceClient for MetadataOnlyHost { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "metadata-fixture" + } + fn is_available(&self) -> bool { + true + } + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + assert_eq!(feature, "ticket"); + Ok(()) + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("metadata-only prompt fixture must not perform protected operations") + } + } + // These builtin resources are the model-visible workflow compatibility contract. // Fixtures prove composition and tool descriptions, not an agent's real judgment. fn ticket_workflow_fixture( @@ -351,7 +374,7 @@ mod tests { mr_config: MergeRequestFeatureConfig, access: TicketFeatureAccess, ) -> (String, Vec) { - let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")); + let client = Arc::new(MetadataOnlyHost); let mut tools = Vec::new(); let mut hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index 07edca94f..98d6c7c5a 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -1987,6 +1987,12 @@ enabled = false "test" } + fn authorize_feature_activation(&self, _feature: &str) -> Result<(), WorkspaceClientError> { + // This fixture represents a non-Subject Host admitting ordinary + // Internal Worker configuration, not an operation permission grant. + Ok(()) + } + fn is_available(&self) -> bool { true } diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index 2929aba0b..fe48b30ee 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -6108,6 +6108,10 @@ mod tests { }; let authoring_client = Arc::new(ScriptedWorkspaceClient::new([ query_response, + crate::worker::WorkspaceResponse { + status: 200, + body: json!({"scope": null}).to_string(), + }, create_response, comment_response, ])); @@ -6189,11 +6193,12 @@ mod tests { .await .unwrap(); let authoring_requests = authoring_client.requests(); - assert_eq!(authoring_requests.len(), 3); + assert_eq!(authoring_requests.len(), 4); assert!(authoring_requests[0].path.ends_with("/tickets/query")); - assert!(authoring_requests[1].path.ends_with("/tickets")); + assert!(authoring_requests[1].path.ends_with("/authorization/check")); + assert!(authoring_requests[2].path.ends_with("/tickets")); assert!( - authoring_requests[2] + authoring_requests[3] .path .ends_with("/tickets/T-9/thread-events") ); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 6af8d337b..dc27dd70f 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -1052,6 +1052,116 @@ pub trait WorkspaceClient: std::fmt::Debug + Send + Sync { self.execute(request) } + /// Ask the Host using this client's authenticated source, never a model-supplied + /// identity. Trusted Features call this before protected effects on every surface. + /// This is not a universal Tool executor: a missing call is a Feature bug, and + /// operations not yet adapted to this seam retain only their existing checks. + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + self.authorize_operation_scope(operation, resource) + .map(|_| ()) + } + + /// Return the Host-selected matching scope for provider-resolved containment. + /// `None` is only the Host's explicit legacy decision, never a transport fallback. + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + use server_api::authorization::{ + OperationAuthorizationRequest, OperationAuthorizationResponse, ScopedPermission, + }; + if !self.is_available() { + return Err(WorkspaceClientError::Unavailable( + "operation authorization requires a Host-bound Workspace client".into(), + )); + } + let workspace_id = self + .workspace_id() + .filter(|id| !id.is_empty()) + .ok_or_else(|| { + WorkspaceClientError::Unavailable( + "operation authorization requires Workspace identity".into(), + ) + })?; + ScopedPermission { + operation: operation.into(), + resource: resource.clone(), + } + .validate() + .map_err(WorkspaceClientError::Request)?; + let request = workspace_server_json_request( + format!( + "/api/w/{}/authorization/check", + encode_workspace_path_segment(workspace_id) + ), + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + )?; + // Blocking transports must not run inside a Tokio runtime. The bound + // transport enforces this timeout; no authorization success is cached. + let execute = || self.execute_with_timeout(request, Duration::from_secs(10)); + let response = if tokio::runtime::Handle::try_current().is_ok() { + std::thread::scope(|scope| scope.spawn(execute).join()).map_err(|_| { + WorkspaceClientError::Request("authorization request thread panicked".into()) + })?? + } else { + execute()? + }; + if response.body.len() > 16 * 1024 { + return Err(WorkspaceClientError::Request( + "authorization response exceeded 16 KiB".into(), + )); + } + if !response.is_success() { + // Expose only the bounded public reason, not provider diagnostics or + // arbitrary response data. Keep status parseable by Feature adapters. + let value: serde_json::Value = serde_json::from_str(&response.body).unwrap_or_default(); + let reason: String = value + .get("message") + .or_else(|| value.get("error")) + .and_then(serde_json::Value::as_str) + .unwrap_or("Host rejected authorization") + .chars() + .take(1024) + .map(|c| if c.is_control() { ' ' } else { c }) + .collect(); + return Err(WorkspaceClientError::Request(format!( + "authorization denied for {operation}: HTTP status {}: {reason}", + response.status + ))); + } + let authorization = serde_json::from_str::(&response.body) + .map_err(|error| { + WorkspaceClientError::Request(format!("invalid authorization response: {error}")) + })?; + if authorization + .scope + .as_ref() + .is_some_and(|scope| !scope.covers(resource)) + { + return Err(WorkspaceClientError::Request( + "Host authorization scope does not cover requested resource".into(), + )); + } + Ok(authorization.scope) + } + + /// Activation is separate from operation authority. Only explicit standalone + /// contexts with no Workspace binding retain legacy activation without a Host. + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.authorize_operation( + feature, + &server_api::authorization::PermissionResource::Feature, + ) + } + /// Binary transport is unavailable unless a host-bound implementation opts in. fn execute_binary( &self, @@ -1170,6 +1280,28 @@ impl WorkspaceClient for ReviewerChildWorkspaceClient { self.inner.current_prompt_projection() } + fn authorize_operation( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result<(), WorkspaceClientError> { + // The query has no protected effect. Keep the review capability's + // mutation filter intact while asking the bound Host for live authority. + self.inner.authorize_operation(operation, resource) + } + + fn authorize_operation_scope( + &self, + operation: &str, + resource: &server_api::authorization::PermissionResource, + ) -> Result, WorkspaceClientError> { + self.inner.authorize_operation_scope(operation, resource) + } + + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + self.inner.authorize_feature_activation(feature) + } + fn execute_binary( &self, request: WorkspaceBinaryRequest, @@ -1322,6 +1454,7 @@ struct MarkerWorkspaceClient { workspace_id: Option, kind: String, available: bool, + legacy_local_activation: bool, reason: String, } @@ -1338,6 +1471,18 @@ impl WorkspaceClient for MarkerWorkspaceClient { self.available } + fn authorize_feature_activation(&self, feature: &str) -> Result<(), WorkspaceClientError> { + // Explicit compatibility for standalone Workers with no Workspace binding. + // A missing/broken Workspace client must never become an implicit grant. + if self.workspace_id.is_none() && self.legacy_local_activation { + return Ok(()); + } + self.authorize_operation( + feature, + &server_api::authorization::PermissionResource::Feature, + ) + } + fn execute( &self, _request: WorkspaceRequest, @@ -1346,6 +1491,196 @@ impl WorkspaceClient for MarkerWorkspaceClient { } } +#[cfg(test)] +mod operation_authorization_tests { + use super::*; + use server_api::authorization::PermissionResource; + + #[derive(Debug)] + struct BoundClient { + response: Mutex, + requests: Mutex>, + } + + impl WorkspaceClient for BoundClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace/test") + } + fn kind(&self) -> &str { + "bound-authorization-fixture" + } + fn is_available(&self) -> bool { + true + } + fn execute(&self, _: WorkspaceRequest) -> Result { + panic!("authorization must select the bounded transport") + } + fn execute_with_timeout( + &self, + request: WorkspaceRequest, + timeout: Duration, + ) -> Result { + assert_eq!(timeout, Duration::from_secs(10)); + self.requests.lock().unwrap().push(request); + Ok(self.response.lock().unwrap().clone()) + } + } + + #[tokio::test] + async fn authorization_is_bounded_live_and_contains_no_caller_identity() { + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: "{}".into(), + }), + requests: Default::default(), + }; + let resource = PermissionResource::Workspace { + workspace_id: "workspace/test".into(), + }; + client + .authorize_operation("ticket.create", &resource) + .unwrap(); + *client.response.lock().unwrap() = WorkspaceResponse { + status: 403, + body: r#"{"message":"permission revoked","diagnostics":[{"message":"private"}]}"# + .into(), + }; + let reason = client + .authorize_operation("ticket.create", &resource) + .unwrap_err() + .to_string(); + assert!(reason.contains("HTTP status 403")); + assert!(reason.contains("permission revoked")); + assert!(!reason.contains("private")); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2, "success must not become a cached grant"); + for request in requests.iter() { + assert_eq!(request.method, WorkspaceRequestMethod::Post); + assert_eq!(request.path, "/api/w/workspace%2Ftest/authorization/check"); + assert_eq!( + serde_json::from_str::(request.body.as_deref().unwrap()) + .unwrap(), + serde_json::json!({ "operation":"ticket.create", "resource":{"kind":"workspace", "workspace_id":"workspace/test"} }) + ); + } + } + + #[test] + fn scoped_authorization_preserves_matching_scope_and_rejects_cross_resource() { + let resource = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs/file.md".into(), + recursive: false, + }; + let granted = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs".into(), + recursive: true, + }; + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":granted}).to_string(), + }), + requests: Default::default(), + }; + assert_eq!( + client + .authorize_operation_scope("filesystem.read", &resource) + .unwrap(), + Some(granted) + ); + *client.response.lock().unwrap() = WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":{"kind":"workspace","workspace_id":"workspace/test"}}) + .to_string(), + }; + assert!( + client + .authorize_operation_scope("filesystem.read", &resource) + .is_err() + ); + } + + #[test] + fn reviewer_query_preserves_host_scope_and_does_not_widen_mutation_access() { + let scope = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs".into(), + recursive: true, + }; + let inner = Arc::new(BoundClient { + response: Mutex::new(WorkspaceResponse { + status: 200, + body: serde_json::json!({"scope":scope}).to_string(), + }), + requests: Default::default(), + }); + let reviewer = ReviewerChildWorkspaceClient::new( + inner.clone(), + ReviewerContext { + ticket_id: "T-1".into(), + merge_request_id: "MR-1".into(), + }, + "capability".into(), + ); + let requested = PermissionResource::Workdir { + working_directory_id: "wd-1".into(), + path: "docs/file.md".into(), + recursive: false, + }; + assert_eq!( + reviewer + .authorize_operation_scope("filesystem.read", &requested) + .unwrap(), + Some(scope) + ); + assert!( + reviewer + .execute(WorkspaceRequest::json( + WorkspaceRequestMethod::Post, + "/api/w/workspace%2Ftest/tickets", + "{}" + )) + .is_err() + ); + assert_eq!( + inner.requests.lock().unwrap().len(), + 1, + "only the bounded authorization query reaches the Host" + ); + *inner.response.lock().unwrap() = WorkspaceResponse { + status: 403, + body: "{}".into(), + }; + assert!( + reviewer + .authorize_operation_scope("filesystem.read", &requested) + .is_err() + ); + } + + #[test] + fn malformed_and_oversized_authorization_responses_fail_closed() { + for body in [ + "not-json".into(), + "{\"unexpected_grant\":true}".into(), + " ".repeat(16 * 1024 + 1), + ] { + let client = BoundClient { + response: Mutex::new(WorkspaceResponse { status: 200, body }), + requests: Default::default(), + }; + assert!( + client + .authorize_operation("ticket", &PermissionResource::Feature) + .is_err() + ); + } + } +} + #[cfg(test)] mod reviewer_client_tests { use super::*; @@ -1587,6 +1922,7 @@ pub fn unavailable_workspace_client( workspace_id: workspace_id.map(|id| id.as_str().to_string()), kind: "unavailable".to_string(), available: false, + legacy_local_activation: workspace_id.is_none(), reason: reason.into(), }) } @@ -1601,6 +1937,7 @@ pub fn marker_workspace_client( reason: format!("workspace client kind `{kind}` does not expose Workspace operations"), kind, available: true, + legacy_local_activation: false, }) } @@ -1649,7 +1986,15 @@ impl WorkerWorkspaceContext { } pub fn local_filesystem(workspace_id: Option) -> Self { - let client = marker_workspace_client(workspace_id.as_ref(), "local-filesystem"); + // This constructor explicitly selects legacy standalone activation only + // without Workspace identity; it never authorizes protected operations. + let client = Arc::new(MarkerWorkspaceClient { + workspace_id: workspace_id.as_ref().map(|id| id.as_str().to_string()), + kind: "local-filesystem".into(), + available: true, + legacy_local_activation: workspace_id.is_none(), + reason: "local filesystem context does not expose Workspace operations".into(), + }); Self { workspace_id, client, @@ -3844,6 +4189,12 @@ impl Worker { registry: FeatureRegistryBuilder, ) -> FeatureRegistryInstallReport { self.backend_job.get_or_init(|| None); + let client = self.workspace_client_handle(); + let manifest = self.manifest().clone(); + let registry = registry.with_activation_guard(move || { + crate::feature::authorize_requested_manifest(client.as_ref(), &manifest) + .map_err(|error| error.to_string()) + }); let worker = self.engine.as_mut().expect("worker taken during run"); let report = registry.install_into_engine(worker, &mut self.hook_builder); if report.has_errors() { @@ -10552,6 +10903,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } let request: server_api::MemoryBackendRequest = serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( @@ -10613,6 +10970,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } let request: server_api::SubjektivMemoryBackendRequest = serde_json::from_str(request.body.as_deref().unwrap_or_default()).unwrap(); assert!(matches!( @@ -10953,6 +11316,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests .lock() .expect("recorded workspace requests lock") @@ -10985,6 +11354,12 @@ mod build_summary_prompt_tests { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests .lock() .expect("Flow source request lock") diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index 1938f0b18..cca6df2a2 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient { fn execute( &self, - _request: WorkspaceRequest, + request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } Err(WorkspaceClientError::Unavailable("test".to_string())) } } @@ -249,8 +255,14 @@ impl WorkspaceClient for NoopWorkspaceClient { fn execute( &self, - _request: WorkspaceRequest, + request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } Err(WorkspaceClientError::Unavailable( "test client does not execute requests".to_string(), )) @@ -912,6 +924,12 @@ impl WorkspaceClient for JournalWorkspaceClient { &self, request: WorkspaceRequest, ) -> Result { + if request.path.ends_with("/authorization/check") { + return Ok(WorkspaceResponse { + status: 200, + body: "{}".into(), + }); + } self.requests.lock().unwrap().push(request.clone()); let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") { ( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f6aea2db6..4027b221a 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -567,6 +567,10 @@ pub struct WorkerSpawnRequest { /// Trusted Backend operation identity used to make Worker-owned spawns replay-safe. #[serde(skip, default)] pub resolved_control_operation: Option, + /// Host-derived child execution ceiling, persisted against the ordinary + /// Worker reservation before Runtime activation. Not accepted from JSON. + #[serde(skip, default)] + pub resolved_execution_grant: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -7892,6 +7896,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8182,6 +8187,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8294,6 +8300,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, @@ -8335,6 +8342,7 @@ mod tests { resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: Some(test_workspace_api()), resolved_memory_settings: Some(test_memory_settings()), resolved_subjektiv_attached: false, diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 1418b0ba1..b8d83ca20 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -26,10 +26,12 @@ use subjektiv::api::*; mod backend_job_profile; +mod subject_authorization; mod subjektiv_job; mod subjektiv_journal; mod subjektiv_journal_browser; mod subjektiv_memory_journal; +mod workdir_authorization; mod worker_operations; mod worker_protocol; pub(crate) use worker_operations::{ @@ -4378,6 +4380,7 @@ impl WorkspaceApi { // main-Subject/Memory attachment or its resident/extraction policy. resolved_subjektiv_attached: request.grants.subjektiv_consolidation.is_some(), resolved_control_operation: Some(control_operation), + resolved_execution_grant: None, }, ) { Ok(spawned) => spawned, @@ -5428,6 +5431,21 @@ impl WorkspaceApi { crate::store::validate_worker_singleton_key(singleton_key)?; } self.validate_worker_spawn_repository_scope(runtime_id, &mut request)?; + if let Some(grant) = request.resolved_execution_grant.as_ref() { + for claim in &mut request.resolved_workdir_attachments { + claim.capabilities = workdir_authorization::intersect_capabilities( + &grant.permissions, + &claim.working_directory_id, + claim.capabilities, + ); + if claim.capabilities == workdir::WorkdirSessionCapabilities::EMPTY { + return Err(Error::WorkspacePermissionDenied( + "attachment has no operation within the delegated execution grant".into(), + ) + .into()); + } + } + } let workspace_api = self.workspace_api_ref(runtime_id); request.resolved_workspace_api = Some(workspace_api.clone()); for access in take_new_workdir_repository_access(&mut request)? { @@ -5527,6 +5545,9 @@ impl WorkspaceApi { }; drop(subject_guard); let worker_id = reservation.worker_id; + if let Some(grant) = request.resolved_execution_grant.as_ref() { + subject_authorization::bind_reserved_grant(self, grant, &worker_id.to_string())?; + } if let Some(assignment) = request.ticket_assignment.as_ref() { self.store.bind_ticket_assignment_operation_worker( &self.config.workspace_id, @@ -7034,6 +7055,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::memory_consolidation( service.clone(), )) + .merge(server_api::server_api_axum::operation_authorize( + service.clone(), + )) + .merge(server_api::server_api_axum::subjektiv_subject_permissions_get(service.clone())) + .merge(server_api::server_api_axum::subjektiv_subject_permissions_set(service.clone())) .merge(server_api::server_api_axum::subjektiv_subject_create( service.clone(), )) @@ -7616,6 +7642,7 @@ fn worker_spawn_request_from_api( resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, }) } @@ -8985,6 +9012,114 @@ impl server_api::ServerApi for ServerApiContractService { .map_err(ApiError::into_repository_api_error) } + async fn operation_authorize( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::authorization::OperationAuthorizationRequest, + ) -> std::result::Result< + server_api::authorization::OperationAuthorizationResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?.clone(); + // Runtime startup/restore can hold Host while asking this callback. The + // authority query uses only current stores, never Runtime or Host reentry. + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let worker = subject_authorization::source_from_context(&context)?; + let scope = subject_authorization::authorize_scope(&api, &worker, &request)?; + Ok(server_api::authorization::OperationAuthorizationResponse { scope }) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_permissions_get( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + subject_id: String, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + let actor = subject_authorization::administration_actor(&context) + .map_err(ApiError::into_repository_api_error)?; + require_workspace_owner( + &api, + &workspace_id, + actor, + "Subject permission administration", + ) + .await + .map_err(ApiError::into_repository_api_error)?; + api.store + .db_executor() + .run(move || -> ApiResult<_> { + Ok(open_subjektiv_store(&api)? + .subject_permissions(&subject_id) + .map(|permissions| server_api::SubjectPermissionsResponse { permissions }) + .map_err(subjektiv_store_error)?) + }) + .await + .map_err(ApiError::into_repository_api_error) + } + + async fn subjektiv_subject_permissions_set( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + subject_id: String, + request: server_api::authorization::SubjectPermissionsUpdate, + ) -> std::result::Result< + server_api::SubjectPermissionsMutationResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?.clone(); + let actor = subject_authorization::administration_actor(&context) + .map_err(ApiError::into_repository_api_error)?; + require_workspace_owner( + &api, + &workspace_id, + actor, + "Subject permission administration", + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let mutation_api = api.clone(); + let mutation_subject = subject_id.clone(); + let (permissions, changed) = api + .store + .db_executor() + .run(move || -> ApiResult<_> { + let store = open_subjektiv_store(&mutation_api)?; + let before = store + .subject_permissions(&mutation_subject) + .map_err(subjektiv_store_error)?; + let was_current = before.as_ref().map(|policy| &policy.change_id) + == request.expected_change_id.as_ref(); + let permissions = store + .set_subject_permissions(&mutation_subject, &request) + .map_err(subjektiv_store_error)?; + let changed = was_current + && before.as_ref().map(|source| &source.change_id) + != Some(&permissions.change_id); + Ok((permissions, changed)) + }) + .await + .map_err(ApiError::into_repository_api_error)?; + let interruptions = if changed { + subject_authorization::interrupt_subject_workers(&api, &subject_id).await + } else { + Vec::new() + }; + Ok(server_api::SubjectPermissionsMutationResponse { + permissions, + interruptions, + }) + } + async fn subjektiv_subject_create( &self, workspace_id: String, @@ -16821,6 +16956,18 @@ fn execute_ticket_rest_operation_on_host( let (result, created_ticket) = match operation { TicketBackendOperation::Create { input } => { + if let Some(source) = source.as_ref() { + subject_authorization::authorize( + api, + &RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), + &server_api::authorization::OperationAuthorizationRequest { + operation: "ticket.create".into(), + resource: server_api::authorization::PermissionResource::Workspace { + workspace_id: workspace_id.into(), + }, + }, + )?; + } let (reference, ticket) = backend.create_with_snapshot(input).map_err(Error::from)?; ( TicketBackendOperationResult::TicketRef(reference), @@ -20810,6 +20957,11 @@ async fn scoped_execute_current_worker_workdir_operation( context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id)); context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id)); let target_workdir = request.target_workdir; + context.stage = "attachment_validation"; + let permission_link = validated_current_worker_attachment_async(&api, &worker, &target_workdir, None).await?; + context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&permission_link.workdir_id)); + context.stage = "operation_authorization"; + workdir_authorization::authorize(&api, &worker, &permission_link, &request.operation)?; let result = match request.operation { WorkdirSessionOperation::CommandStart(command) => { let session_lock = current_worker_session_lock(&api, &worker); @@ -20901,6 +21053,7 @@ async fn scoped_execute_current_worker_workdir_operation( context.stage = "session_open"; let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; + let source = worker::authorized_workdir::authorized_workdir_session(source, subject_authorization::bind_operation_client(&api, &worker), link.workdir_id.clone()); execute_workdir_session_operation(&source, operation) .await .map_err(workdir_operation_transport_error)? @@ -23887,6 +24040,14 @@ async fn scoped_subjektiv_memory_backend( fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { match error { + crate::subjektiv::SubjektivError::AuthorizationDenied(_) => { + Error::WorkspacePermissionDenied(error.to_string()) + } + crate::subjektiv::SubjektivError::SubjectPermissionsConflict(_) + | crate::subjektiv::SubjektivError::PermissionOperationConflict(_) + | crate::subjektiv::SubjektivError::WorkerExecutionGrantConflict(_) => { + Error::RepositoryConflict(error.to_string()) + } crate::subjektiv::SubjektivError::SubjectScopeMismatch { .. } => { Error::WorkspacePermissionDenied(format!("subject_scope_mismatch: {error}")) } @@ -24139,6 +24300,7 @@ fn start_memory_staging_consolidation( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, )?; if result.state != InternalWorkerOperationState::Accepted { @@ -25512,6 +25674,38 @@ fn authorize_known_worker_permission( subject: &RuntimeWorkerRef, permission: &str, ) -> Result { + // Child result return is deliberately narrower than control or public chat + // posting. The execution grant names only this origin Worker and only notify. + if permission == "notify" { + if let Some(permissions) = subject_authorization::worker_permissions(api, controller) + .map_err(|error| error.error)? + { + let requested = server_api::authorization::ScopedPermission { + operation: "worker.result".into(), + resource: server_api::authorization::PermissionResource::Worker { + runtime_id: subject.runtime_id.clone(), + worker_id: subject.worker_id.clone(), + }, + }; + if permissions.iter().any(|granted| granted.covers(&requested)) { + return Ok(WorkerControlGrantRecord { + workspace_id: workspace_id.into(), + grant_id: format!( + "execution-result:{}/{}", + controller.runtime_id, controller.worker_id + ), + controller: controller.clone(), + subject: subject.clone(), + relation: "result_return".into(), + origin: "execution_grant".into(), + permissions: vec!["notify".into()], + operation_id: "execution-result".into(), + created_at: String::new(), + revoked_at: None, + }); + } + } + } let grant = api .store .get_active_worker_control_grant(workspace_id, controller, subject)? @@ -25899,6 +26093,7 @@ async fn scoped_start_workspace_orchestrator( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, )?; if result.state != InternalWorkerOperationState::Accepted || result.worker.is_none() { @@ -31769,6 +31964,7 @@ async fn create_workspace_worker_authenticated_on_host( initial_submit, workdir_attachments, feature_connections, + execution_grant, control_operation_id: _, } = request; let delegation = ticket_assignment @@ -31828,6 +32024,38 @@ async fn create_workspace_worker_authenticated_on_host( &profile, prompt_catalog.as_ref(), )?; + let resolved_execution_grant = if let Some(source) = source.as_ref() { + let resolved = resolved_config_bundle + .as_ref() + .ok_or_else(|| Error::Config("resolved Worker Profile bundle is missing".into()))?; + let profile_digest = format!( + "sha256:{}", + Sha256::digest( + serde_json::to_vec(resolved).map_err(|error| Error::Config(error.to_string()))? + ) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + subject_authorization::prepare_delegation( + &api, + &RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), + execution_grant.as_ref(), + &runtime_id, + &profile, + &profile_digest, + singleton_key.as_deref(), + feature_connections.subjektiv.is_some(), + )? + } else { + if execution_grant.is_some() { + return Err(Error::WorkspacePermissionDenied( + "execution grants require an authenticated delegation origin".into(), + ) + .into()); + } + None + }; let resolved_subjektiv_attached = match feature_connections.subjektiv { None => { validate_caller_worker_singleton_key(singleton_key.as_deref())?; @@ -31907,6 +32135,7 @@ async fn create_workspace_worker_authenticated_on_host( resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation, + resolved_execution_grant, resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached, @@ -37024,6 +37253,7 @@ mod tests { mod external_checkout_tests; mod protocol_backpressure_tests; mod restored_parent_control_tests; + mod subject_authorization_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; mod subjektiv_journal_tests; @@ -38111,6 +38341,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -38699,6 +38930,7 @@ mod tests { ], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -38938,6 +39170,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42195,6 +42428,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; assert!( api.validate_worker_spawn_repository_scope("runtime-1", &mut workdir_flow_launch) @@ -42454,6 +42688,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; assert!( @@ -42548,6 +42783,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await; @@ -42583,6 +42819,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42619,6 +42856,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42710,6 +42948,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -42776,7 +43015,9 @@ mod tests { async fn production_profile_backend_restores_zero_input_orchestrator() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); - let config = test_server_config(workspace.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = test_server_config(workspace.path()); + config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap())); let store = Arc::new(SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap()); seed_test_registered_workspace(store.as_ref(), &config) @@ -42784,6 +43025,8 @@ mod tests { .unwrap(); let api = WorkspaceApi::new_for_test(config, store).await.unwrap(); let workspace_id = api.config.workspace_id.clone(); + let _callback_server = + AuthorizationCallbackServer::start(listener, build_router(api.clone())); let result = scoped_start_workspace_orchestrator( State(api.clone()), @@ -42970,6 +43213,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43465,6 +43709,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43766,6 +44011,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -43799,6 +44045,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: Some("control-spawn-retry".to_string()), + execution_grant: None, }; let Json(first) = spawn_known_worker( @@ -43875,6 +44122,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let Json(created) = @@ -44039,6 +44287,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -44138,6 +44387,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }; let Json(controller) = create_workspace_worker( State(api.clone()), @@ -44352,6 +44602,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -45374,6 +45625,7 @@ mod tests { }), }, control_operation_id: None, + execution_grant: None, } }; @@ -45506,6 +45758,7 @@ mod tests { }), }, control_operation_id: None, + execution_grant: None, }; let Json(created) = scoped_create_workspace_worker( State(api.clone()), @@ -46693,6 +46946,7 @@ mod tests { resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached: false, + resolved_execution_grant: None, resolved_control_operation: Some(WorkerControlOperation { operation_id: operation_id.to_string(), input_fingerprint: format!("sha256:{operation_id}"), @@ -46936,6 +47190,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -46956,6 +47211,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -46998,6 +47254,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -47041,6 +47298,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -48526,6 +48784,22 @@ mod tests { ); } + struct AuthorizationCallbackServer(tokio::task::JoinHandle<()>); + + impl AuthorizationCallbackServer { + fn start(listener: tokio::net::TcpListener, router: Router) -> Self { + Self(tokio::spawn(async move { + axum::serve(listener, router).await.unwrap(); + })) + } + } + + impl Drop for AuthorizationCallbackServer { + fn drop(&mut self) { + self.0.abort(); + } + } + fn test_server_config(workspace_root: impl Into) -> ServerConfig { let workspace_root = workspace_root.into(); let store_root = workspace_root.join(".test-embedded-runtime-store"); @@ -48605,6 +48879,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -50022,6 +50297,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -50705,13 +50981,16 @@ mod tests { #[tokio::test] async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() { let temp = tempfile::tempdir().unwrap(); - let config = test_server_config(temp.path()); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let mut config = test_server_config(temp.path()); + config.backend_base_url = Some(format!("http://{}", listener.local_addr().unwrap())); let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let owner = seed_test_api_token(store.as_ref(), "empty-owner"); let foreign = seed_test_api_token(store.as_ref(), "empty-foreign"); let app = build_workspace_server_router(config.clone(), store.clone()) .await .unwrap(); + let _callback_server = AuthorizationCallbackServer::start(listener, app.clone()); let wire = json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"}); request_json( @@ -54061,6 +54340,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -54457,6 +54737,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -54674,6 +54955,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -55136,6 +55418,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -55828,6 +56111,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -55931,6 +56215,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let source_worker = api .runtime @@ -56486,6 +56771,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -56554,6 +56840,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -58286,6 +58573,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -58367,6 +58655,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -58507,6 +58796,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -61701,6 +61991,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -64376,6 +64667,7 @@ mod tests { workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }, None, ) @@ -65161,6 +65453,7 @@ mod tests { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .expect("spawn worker"); @@ -66612,6 +66905,7 @@ mod tests { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let spawned = api .spawn_workspace_worker(EMBEDDED_WORKER_RUNTIME_ID, spawn_request) @@ -67479,6 +67773,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; let dedicated_key = "subjektiv-consolidation:dedicated-subject"; diff --git a/crates/workspace-server/src/server/subject_authorization.rs b/crates/workspace-server/src/server/subject_authorization.rs new file mode 100644 index 000000000..feaafb508 --- /dev/null +++ b/crates/workspace-server/src/server/subject_authorization.rs @@ -0,0 +1,484 @@ +//! Host binding for cooperative Feature authorization. Profile configuration requests +//! capabilities; it never issues them. Non-Subject Workers retain their existing +//! Backend/provider contracts. Subject bodies with no assignment deny by default. +//! +//! Bindings here are execution grants, not another Worker registry. Current body +//! ownership remains the singleton store. Replacing a permission assignment fences +//! all children issued from it; restoring a Worker cannot refresh that assignment. +//! Checks refuse new effects; they do not undo effects already committed. Trusted +//! Features must call the operation boundary themselves, including alternate surfaces. + +use super::*; +use server_api::authorization::{ + OperationAuthorizationRequest, PermissionResource, ScopedPermission, SubjectPermissions, + WorkerDelegationRequest, WorkerExecutionGrant, +}; +use std::collections::BTreeSet; + +fn denied(message: impl Into) -> ApiError { + Error::WorkspacePermissionDenied(message.into()).into() +} + +fn policy(api: &WorkspaceApi, subject_id: &str) -> ApiResult> { + let store = open_subjektiv_store(api)?; + let subject = store + .subject(subject_id) + .map_err(subjektiv_store_error)? + .ok_or_else(|| denied("Subject permission source no longer exists"))?; + if subject.state != crate::subjektiv::SubjectState::Active { + return Err(denied("Subject permission source is retired")); + } + Ok(store + .subject_permissions(subject_id) + .map_err(subjektiv_store_error)?) +} + +fn current_body_subject( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult> { + let retained = api + .store + .retained_worker_singleton_lease(&api.config.workspace_id, worker)?; + let Some(retained) = retained else { + return Ok(None); + }; + let Some(subject) = retained.key.strip_prefix(SUBJEKTIV_SINGLETON_PREFIX) else { + return Ok(None); + }; + // This also rejects a retained lease from a released/replaced body. Never + // fall through to non-Subject compatibility after ownership has ended. + let current = api + .store + .require_current_worker_singleton_owner(&api.config.workspace_id, worker)? + .ok_or_else(|| denied("Subject body no longer owns its singleton"))?; + if current.key != retained.key { + return Err(denied("Subject body ownership changed")); + } + Ok(Some(subject.to_owned())) +} + +fn live_child_grant( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult> { + let store = open_subjektiv_store(api)?; + let Some(grant) = store + .worker_execution_grant(&worker.runtime_id, &worker.worker_id) + .map_err(subjektiv_store_error)? + else { + return Ok(None); + }; + let source = + policy(api, &grant.subject_id)?.ok_or_else(|| denied("Subject permissions are unset"))?; + if source.change_id != grant.source_change_id { + return Err(denied( + "delegated execution grant was revoked by permission replacement", + )); + } + let mut current = grant.clone(); + let mut visited = HashSet::new(); + for _ in 0..64 { + if !visited.insert((current.runtime_id.clone(), current.worker_id.clone())) { + return Err(denied("cyclic execution grant lineage")); + } + if current.subject_id != grant.subject_id + || current.source_change_id != source.change_id + || current + .permissions + .iter() + .chain(current.redelegation.iter()) + .any(|permission| { + !source + .delegation + .iter() + .any(|ceiling| ceiling.covers(permission)) + }) + { + return Err(denied( + "delegated execution exceeds the current Subject ceiling", + )); + } + let origin = RuntimeWorkerRef::new(¤t.origin_runtime_id, ¤t.origin_worker_id); + match store + .worker_execution_grant(&origin.runtime_id, &origin.worker_id) + .map_err(subjektiv_store_error)? + { + Some(parent) => { + if current + .permissions + .iter() + .chain(current.redelegation.iter()) + .any(|permission| { + !parent + .redelegation + .iter() + .any(|ceiling| ceiling.covers(permission)) + }) + { + return Err(denied( + "execution grant exceeds its immediate parent's redelegation ceiling", + )); + } + current = parent; + } + None => { + if current_body_subject(api, &origin)?.as_deref() != Some(grant.subject_id.as_str()) + { + return Err(denied( + "delegation origin is no longer the current Subject body", + )); + } + return Ok(Some(grant)); + } + } + } + Err(denied( + "execution grant lineage exceeds the bounded delegation depth", + )) +} + +/// None is explicitly the preexisting non-Subject contract, not an all-powerful +/// grant. Existing Workdir/provider/Ticket checks still apply to those Workers. +pub(super) fn worker_permissions( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult>> { + if let Some(grant) = live_child_grant(api, worker)? { + return Ok(Some(grant.permissions)); + } + let Some(subject) = current_body_subject(api, worker)? else { + return Ok(None); + }; + Ok(Some( + policy(api, &subject)? + .map(|policy| policy.execution) + .unwrap_or_default(), + )) +} + +pub(super) fn authorize( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: &OperationAuthorizationRequest, +) -> ApiResult<()> { + authorize_scope(api, worker, request).map(|_| ()) +} + +pub(super) fn authorize_scope( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + request: &OperationAuthorizationRequest, +) -> ApiResult> { + request.validate().map_err(denied)?; + if let PermissionResource::Workspace { workspace_id } = &request.resource { + if workspace_id != &api.config.workspace_id { + return Err(denied("resource belongs to another Workspace")); + } + } + let needed = ScopedPermission { + operation: request.operation.clone(), + resource: request.resource.clone(), + }; + let Some(permissions) = worker_permissions(api, worker)? else { + return Ok(None); + }; + if let Some(permission) = permissions + .iter() + .find(|permission| permission.covers(&needed)) + { + return Ok(Some(permission.resource.clone())); + } + Err(denied(format!( + "operation `{}` is not granted for the requested resource", + request.operation + ))) +} + +pub(super) fn source_from_context( + context: &server_api::ServerRequestContext, +) -> ApiResult { + let source = context + .runtime_source + .as_ref() + .ok_or_else(|| denied("authorization requires a verified Runtime source"))?; + let worker_id = source + .worker_id + .as_deref() + .ok_or_else(|| denied("authorization requires Runtime-bound Worker identity"))?; + Ok(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) +} + +/// Resolves authority from the authenticated origin, independently of its enabled +/// Feature/tool set. A model cannot nominate a Subject or inherit body identity. +pub(super) fn prepare_delegation( + api: &WorkspaceApi, + source: &RuntimeWorkerRef, + requested: Option<&WorkerDelegationRequest>, + runtime_id: &str, + profile: &str, + profile_digest: &str, + singleton_key: Option<&str>, + subject_connection: bool, +) -> ApiResult> { + let child = live_child_grant(api, source)?; + let body = if child.is_none() { + current_body_subject(api, source)? + } else { + None + }; + if child.is_none() && body.is_none() { + if requested.is_some() { + return Err(denied( + "only an authenticated Subject delegation source can issue an execution grant", + )); + } + return Ok(None); + } + if singleton_key.is_some() || subject_connection { + return Err(denied( + "delegated Workers cannot acquire a Subject body or singleton identity", + )); + } + let requested = requested.ok_or_else(|| { + denied("Subject delegation requires explicit least-authority permissions") + })?; + requested.validate().map_err(denied)?; + let (subject_id, origin_runtime_id, origin_worker_id, source_change_id, ceiling) = match child { + Some(grant) => ( + grant.subject_id, + source.runtime_id.clone(), + source.worker_id.clone(), + grant.source_change_id, + grant.redelegation, + ), + None => { + let subject = body.expect("checked Subject body"); + let source_policy = policy(api, &subject)?.ok_or_else(|| { + denied( + "Subject permissions are unset; an authorized administrator must assign them", + ) + })?; + ( + subject, + source.runtime_id.clone(), + source.worker_id.clone(), + source_policy.change_id, + source_policy.delegation, + ) + } + }; + if requested + .permissions + .iter() + .chain(requested.redelegation.iter()) + .any(|permission| !ceiling.iter().any(|allowed| allowed.covers(permission))) + { + return Err(denied( + "requested delegation exceeds explicit operations, resources or redelegation ceiling", + )); + } + if requested.redelegation.iter().any(|permission| { + !requested + .permissions + .iter() + .any(|allowed| allowed.covers(permission)) + }) { + return Err(denied( + "redelegation must be within this Worker's effective grant", + )); + } + Ok(Some(WorkerExecutionGrant { + subject_id, + origin_runtime_id, + origin_worker_id, + source_change_id, + runtime_id: runtime_id.to_owned(), + worker_id: String::new(), + profile: profile.to_owned(), + profile_digest: profile_digest.to_owned(), + permissions: requested.permissions.clone(), + redelegation: requested.redelegation.clone(), + })) +} + +pub(super) fn bind_reserved_grant( + api: &WorkspaceApi, + grant: &WorkerExecutionGrant, + worker_id: &str, +) -> ApiResult<()> { + let mut bound = grant.clone(); + bound.worker_id = worker_id.to_owned(); + Ok(open_subjektiv_store(api)? + .bind_worker_execution_grant(&bound) + .map_err(subjektiv_store_error)?) +} + +/// Host-owned binding for an already authenticated Backend operation. No model +/// input can choose the captured Worker or turn a shared client into a grant. +#[derive(Clone)] +struct BoundOperationClient { + api: WorkspaceApi, + worker: RuntimeWorkerRef, +} +impl std::fmt::Debug for BoundOperationClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("BoundOperationClient") + .field("worker", &self.worker) + .finish_non_exhaustive() + } +} +impl worker::WorkspaceClient for BoundOperationClient { + fn workspace_id(&self) -> Option<&str> { + Some(&self.api.config.workspace_id) + } + fn kind(&self) -> &str { + "backend-bound-operation" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + _: worker::WorkspaceRequest, + ) -> std::result::Result { + Err(worker::WorkspaceClientError::Unavailable( + "this binding provides authorization only".into(), + )) + } + fn authorize_operation( + &self, + operation: &str, + resource: &PermissionResource, + ) -> std::result::Result<(), worker::WorkspaceClientError> { + authorize( + &self.api, + &self.worker, + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + ) + .map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string())) + } + fn authorize_operation_scope( + &self, + operation: &str, + resource: &PermissionResource, + ) -> std::result::Result, worker::WorkspaceClientError> { + authorize_scope( + &self.api, + &self.worker, + &OperationAuthorizationRequest { + operation: operation.into(), + resource: resource.clone(), + }, + ) + .map_err(|error| worker::WorkspaceClientError::Request(error.error.to_string())) + } +} + +pub(super) fn bind_operation_client( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Arc { + Arc::new(BoundOperationClient { + api: api.clone(), + worker: worker.clone(), + }) +} + +pub(super) async fn interrupt_subject_workers( + api: &WorkspaceApi, + subject_id: &str, +) -> Vec { + let mut workers = BTreeSet::new(); + let collected = (|| -> ApiResult<()> { + for grant in open_subjektiv_store(api)? + .list_subject_worker_execution_grants(subject_id) + .map_err(subjektiv_store_error)? + { + workers.insert((grant.runtime_id, grant.worker_id)); + } + let key = crate::subjektiv::subject_worker_singleton_key(subject_id) + .map_err(subjektiv_store_error)?; + if let Some(body) = api + .store + .current_worker_singleton_owner(&api.config.workspace_id, &key)? + { + workers.insert((body.worker.runtime_id, body.worker.worker_id)); + } + Ok(()) + })(); + if let Err(error) = collected { + return vec![server_api::SubjectPermissionInterruption { + runtime_id: String::new(), + worker_id: String::new(), + cancellation_accepted: false, + backend_command_sessions_closed: false, + diagnostics: vec![format!( + "permissions committed; interruption targets could not be fully collected: {}", + error.error + )], + }]; + } + let mut results = Vec::new(); + for (runtime_id, worker_id) in workers { + let worker = RuntimeWorkerRef::new(&runtime_id, &worker_id); + let mut evidence = server_api::SubjectPermissionInterruption { + runtime_id, + worker_id, + cancellation_accepted: false, + backend_command_sessions_closed: false, + diagnostics: Vec::new(), + }; + let cancellation_api = api.clone(); + let cancellation_worker = worker.clone(); + let cancellation = match api.host_executors.host() { + Ok(host) => host.run(move || -> ApiResult<_> { + Ok(cancellation_api.runtime.cancel_worker(&cancellation_worker, WorkerLifecycleRequest { + reason: Some("Subject permission assignment changed; new operations are fenced".into()), ticket_assignment: None, + }).map_err(RuntimeRegistryError::into_error)?) + }).await, + Err(error) => Err(error.into()), + }; + match cancellation { + Ok(result) => { + evidence.cancellation_accepted = + result.state == InternalWorkerOperationState::Accepted; + evidence.diagnostics.extend( + result + .diagnostics + .into_iter() + .map(|diagnostic| diagnostic.message), + ); + } + Err(error) => evidence.diagnostics.push(format!( + "Runtime cancellation acceptance unknown or rejected: {}", + error.error + )), + } + match close_worker_workdir_sessions(&api.workdir_sessions, &worker).await { + Ok(()) => evidence.backend_command_sessions_closed = true, + Err(error) => evidence + .diagnostics + .push(format!("Backend command cleanup not confirmed: {error}")), + } + results.push(evidence); + } + results +} + +pub(super) fn administration_actor( + context: &server_api::ServerRequestContext, +) -> ApiResult<&RequestActor> { + if context.runtime_source.is_some() || context.worker_source.is_some() { + return Err(denied( + "Subject permission administration is not a Worker capability", + )); + } + context + .actor + .as_ref() + .ok_or_else(|| denied("Subject permission administration requires an authenticated owner")) +} diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 3c9864195..4c64ba89f 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -105,6 +105,7 @@ async fn cleanup_retention_failures_preserve_cause_on_execution_and_recovery() { resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap(); @@ -279,6 +280,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { resolved_memory_settings: Some(test_worker_memory_settings()), resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() @@ -682,13 +684,24 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t let mut fixture = manual_worker_assignment_fixture().await; let root = tempfile::tempdir().unwrap(); let provider_root = root.path().to_path_buf(); + // Use an attachment-relative path so Host resource validation succeeds; + // the provider's narrower scope still owns this read refusal. + let provider_scope = manifest::Scope::writable(&provider_root) + .unwrap() + .with_added_deny_rules([manifest::ScopeRule { + target: provider_root.join("private"), + permission: manifest::Permission::Read, + recursive: true, + symlink_policy: manifest::SymlinkPolicy::Resolved, + }]) + .unwrap(); // Fresh sessions exercise the real opening/broker/dispatch path on each request. fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| { Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound( workdir::Workdir::new(id), provider_root.clone(), provider_root.clone(), - manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()), + manifest::SharedScope::new(provider_scope.clone()), Caps::READ_ONLY, )) as WorkdirSessionHandle) })); @@ -708,7 +721,7 @@ async fn self_workdir_provider_refusals_log_operation_identity_without_request_t ); let cases = [ ( - json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}), + json!({"operation": "read", "request": {"path": "private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}), "read", "out_of_scope", Reason::PathOutOfScope, diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index 4b994fbd5..3651d6174 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -325,6 +325,7 @@ async fn workers(api: &WorkspaceApi) -> (WorkdirlessFixtureRuntime, Vec RuntimeWorkerRef { workdir_attachments: vec![], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/tests/subject_authorization_tests.rs b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs new file mode 100644 index 000000000..067b02f8e --- /dev/null +++ b/crates/workspace-server/src/server/tests/subject_authorization_tests.rs @@ -0,0 +1,1530 @@ +//! Normal Server library boundary tests (`subject_authorization_tests` filter). +//! Router authentication, Host creation/reservation, Subject policy storage, Worker +//! Feature installation and Ticket SQLite writes are real. Only Runtime execution +//! is replaced by WorkdirlessFixtureRuntime; the unused model never runs. These +//! are not product E2E tests and do not claim OS isolation or child tool execution. +use super::*; +use agen::tool::{Tool, ToolExecutionContext, ToolOutput}; +use server_api::authorization::{ + PermissionResource, ScopedPermission, SubjectPermissions, SubjectPermissionsUpdate, +}; +use std::sync::Mutex; +use ticket::NewTicket; +use worker::feature::builtin::ticket::{TicketFeature, TicketFeatureAccess}; +use worker::{ + WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, + WorkspaceResponse, +}; + +fn activation(name: &str) -> ScopedPermission { + ScopedPermission { + operation: name.into(), + resource: PermissionResource::Feature, + } +} +fn create_permission() -> ScopedPermission { + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: TEST_WORKSPACE_ID.into(), + }, + } +} +fn authorization_path() -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/authorization/check") +} +async fn response(api: &WorkspaceApi, request: Request) -> (StatusCode, Value) { + let response = build_router(api.clone()).oneshot(request).await.unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + let body = serde_json::from_slice(&bytes) + .unwrap_or_else(|_| json!({"text":String::from_utf8_lossy(&bytes)})); + (status, body) +} +fn human_request(token: &str, method: &str, path: &str, body: Option) -> Request { + Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + body.map(|body| body.to_string()).unwrap_or_default(), + )) + .unwrap() +} + +struct Fixture { + api: WorkspaceApi, + identity: Arc, + runtime: WorkdirlessFixtureRuntime, + subject: String, + source: RuntimeWorkerRef, + owner: String, + _dir: tempfile::TempDir, +} +impl Fixture { + async fn new() -> Self { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let subject = open_subjektiv_store(&api) + .unwrap() + .create_subject(crate::subjektiv::SubjectRole::new("authorization-boundary").unwrap()) + .unwrap() + .id; + // A normal body attachment, not a forged registry/singleton row. No + // Workdir or Ticket exists. The profile deliberately requests only reads; + // its enabled tool set must not become a ceiling on explicit delegation. + install_read_only_subject_profile(&api); + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: WorkdirlessFixtureRuntime::RUNTIME_ID.into(), + display_name: "Read-only Subject body".into(), + singleton_key: None, + profile: Some("project:authority-source".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { + subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { + subject_id: subject.clone(), + }), + }, + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + let source = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + let identity = Arc::new(RuntimeIdentityMaterial::generate(&source.runtime_id).unwrap()); + configure_runtime_request_auth(&mut api, &identity, &source.runtime_id); + // The execution fixture does not bootstrap a Session. Exercise the real + // signed startup callback explicitly so the policy store sees the active + // Journal writer, just as it does after normal Runtime startup. + let (status, body) = response(&api, runtime_source_request(&identity, Some(&source.worker_id), "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"), + serde_json::to_vec(&json!({"session_id":session_store::new_session_id().to_string(),"create_if_missing":true})).unwrap())).await; + assert_eq!(status, StatusCode::OK, "source startup callback: {body}"); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "subject-authority-owner").await; + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_empty() + ); + assert!( + api.store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &source) + .unwrap() + .is_some() + ); + Self { + api, + identity, + runtime, + subject, + source, + owner, + _dir: dir, + } + } + fn policy_path(&self) -> String { + format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", + self.subject + ) + } + async fn assign( + &self, + execution: Vec, + delegation: Vec, + ) -> SubjectPermissions { + let previous = open_subjektiv_store(&self.api) + .unwrap() + .subject_permissions(&self.subject) + .unwrap(); + let update = SubjectPermissionsUpdate { + operation_id: Uuid::now_v7().to_string(), + expected_change_id: previous.map(|policy| policy.change_id), + execution, + delegation, + }; + let (status, body) = response( + &self.api, + human_request( + &self.owner, + "POST", + &self.policy_path(), + Some(serde_json::to_value(update).unwrap()), + ), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let mutation: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert!( + mutation + .interruptions + .iter() + .all(|evidence| evidence.backend_command_sessions_closed), + "Backend cleanup evidence is independent of Runtime cancellation acceptance: {:?}", + mutation.interruptions + ); + mutation.permissions + } + async fn check( + &self, + worker: &RuntimeWorkerRef, + permission: &ScopedPermission, + ) -> (StatusCode, Value) { + response( + &self.api, + runtime_source_request( + &self.identity, + Some(&worker.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(permission).unwrap(), + ), + ) + .await + } + fn client(&self, worker: &RuntimeWorkerRef) -> Arc { + Arc::new(RouterClient { + api: self.api.clone(), + identity: self.identity.clone(), + worker: worker.clone(), + handle: tokio::runtime::Handle::current(), + requests: Mutex::new(vec![]), + }) + } + fn child_request(&self, operation: &str, permissions: Vec) -> Value { + json!({"runtime_id":self.source.runtime_id,"display_name":"Ordinary delegated editor", + "profile":"builtin:coder","initial_submit":[],"workdir_attachments":[],"feature_connections":{}, + "control_operation_id":operation,"execution_grant":{"permissions":permissions,"redelegation":[]}}) + } + async fn spawn(&self, source: &RuntimeWorkerRef, body: Value) -> (StatusCode, Value) { + response( + &self.api, + runtime_source_request( + &self.identity, + Some(&source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"), + serde_json::to_vec(&body).unwrap(), + ), + ) + .await + } + async fn child(&self, operation: &str, permissions: Vec) -> RuntimeWorkerRef { + let (status, body) = self + .spawn(&self.source, self.child_request(operation, permissions)) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ) + } + // Historical disconnection is fixture input, not evidence that this suite + // stopped a running Subject controller. Production lease release is real. + fn release_source_lease(&self) { + let lease = self + .api + .store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &self.source) + .unwrap() + .unwrap(); + let binding = "fixture-disconnected-writer"; + let operation = "fixture-disconnected-source"; + self.api + .config_store + .reserve_subject_release(TEST_WORKSPACE_ID, &lease, binding, operation) + .unwrap(); + self.api + .config_store + .confirm_subject_release_proof(TEST_WORKSPACE_ID, &lease, binding, operation, false) + .unwrap(); + self.api + .config_store + .release_subject_singleton(TEST_WORKSPACE_ID, &lease, binding, operation) + .unwrap(); + } +} + +fn install_read_only_subject_profile(api: &WorkspaceApi) { + install_subject_profiles(api, "workspace_read"); +} +fn install_subject_profiles(api: &WorkspaceApi, editor_scope: &str) { + let state = api + .config_store + .load_workspace_config(TEST_WORKSPACE_ID) + .unwrap() + .unwrap(); + let main = state + .snapshot + .get(&config_source::VirtualPath::parse("main.dcdl").unwrap()) + .unwrap(); + let content = r#"{ profile = { default_profile = "project:authority-source"; entries = [{ selector = "project:authority-source"; label = "Read-only Subject"; description = "Boundary fixture"; + profile = (import "$builtin/profiles/companion.dcdl") // { slug = "authority-source"; scope = "workspace_read"; delegation_scope = "workspace_read"; }; + }, { selector = "project:authority-editor"; label = "Editor"; description = "Mutable Profile boundary fixture"; + profile = (import "$builtin/profiles/coder.dcdl") // { slug = "authority-editor"; scope = "EDITOR_SCOPE"; }; + }]; }; }"#.replace("EDITOR_SCOPE", editor_scope); + commit_workspace_config_tree( + api, + TEST_WORKSPACE_ID, + &ConfigCommitRequest { + base_digest: state.snapshot.digest.clone(), + entrypoints: state.contract.entrypoints.clone(), + changes: vec![config_source::ConfigTreeChange::Update { + path: main.path.clone(), + expected_digest: main.content_digest.clone(), + content: content.into(), + }], + }, + ) + .unwrap(); +} + +/// Transport only: a fresh real signed proof passes the production router on +/// every call. The request log observes whether a denied Tool/WIP operation +/// crossed the Ticket backend HTTP boundary, rather than inferring from no row. +struct RouterClient { + api: WorkspaceApi, + identity: Arc, + worker: RuntimeWorkerRef, + handle: tokio::runtime::Handle, + requests: Mutex>, +} +impl std::fmt::Debug for RouterClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SignedAuthorizationRouterClient") + .field("worker", &self.worker) + .finish_non_exhaustive() + } +} +impl WorkspaceClient for RouterClient { + fn workspace_id(&self) -> Option<&str> { + Some(TEST_WORKSPACE_ID) + } + fn kind(&self) -> &str { + "signed-in-process" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> std::result::Result { + let method = match request.method { + WorkspaceRequestMethod::Get => "GET", + WorkspaceRequestMethod::Post => "POST", + WorkspaceRequestMethod::Put => "PUT", + WorkspaceRequestMethod::Patch => "PATCH", + WorkspaceRequestMethod::Delete => "DELETE", + }; + self.requests + .lock() + .unwrap() + .push((method.into(), request.path.clone())); + let request = runtime_source_request( + &self.identity, + Some(&self.worker.worker_id), + method, + &request.path, + request.body.unwrap_or_default().into_bytes(), + ); + self.handle.block_on(async { + let response = build_router(self.api.clone()) + .oneshot(request) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + let status = response.status().as_u16(); + let bytes = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?; + Ok(WorkspaceResponse { + status, + body: String::from_utf8(bytes.to_vec()) + .map_err(|e| WorkspaceClientError::Request(e.to_string()))?, + }) + }) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn signed_current_body_uses_explicit_policy_and_old_unset_body_denies() { + let f = Fixture::new().await; + assert!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap() + .is_none() + ); + for permission in [activation("ticket"), create_permission()] { + let (status, body) = f.check(&f.source, &permission).await; + assert_eq!(status, StatusCode::FORBIDDEN, "unset policy: {body}"); + } + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + for permission in [activation("ticket"), create_permission()] { + let (status, body) = f.check(&f.source, &permission).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body, + json!({"scope":permission.resource}), + "authorization exposes only effective resource scope, not identities or policy/binding material" + ); + } + for permission in [ + activation("web"), + ScopedPermission { + operation: "ticket.create".into(), + resource: PermissionResource::Workspace { + workspace_id: "another-workspace".into(), + }, + }, + ] { + assert_eq!( + f.check(&f.source, &permission).await.0, + StatusCode::FORBIDDEN + ); + } + // A non-Subject Worker keeps the preexisting contract, but cannot nominate a Subject. + let Json(legacy) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: f.source.runtime_id.clone(), + display_name: "Unattached legacy Worker".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + let legacy = RuntimeWorkerRef::new(legacy.runtime_id, legacy.worker_id); + let (status, body) = f.check(&legacy, &activation("ticket")).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body, + json!({"scope":null}), + "legacy response carries no authority identities" + ); + let (status, _) = f + .spawn( + &legacy, + f.child_request("legacy-cannot-issue", vec![activation("ticket")]), + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn authorization_rejects_missing_source_unknown_identity_fields_and_tampered_signed_body() { + let f = Fixture::new().await; + f.assign(vec![activation("ticket")], vec![]).await; + let body = serde_json::to_value(activation("ticket")).unwrap(); + let (status, error) = response( + &f.api, + human_request(&f.owner, "POST", &authorization_path(), Some(body.clone())), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "human actor is not a Worker source: {error}" + ); + let (status, error) = response( + &f.api, + runtime_source_request( + &f.identity, + None, + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "Runtime without Worker: {error}" + ); + let unsigned = Request::builder() + .method("POST") + .uri(authorization_path()) + .header(CONTENT_TYPE, "application/json") + .header("x-yoi-runtime-id", &f.source.runtime_id) + .header("x-yoi-worker-id", &f.source.worker_id) + .body(Body::from(body.to_string())) + .unwrap(); + assert_eq!(response(&f.api, unsigned).await.0, StatusCode::UNAUTHORIZED); + let mut hinted = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&activation("web")).unwrap(), + ); + hinted + .headers_mut() + .insert("x-yoi-runtime-id", "unattached-runtime".parse().unwrap()); + hinted + .headers_mut() + .insert("x-yoi-worker-id", "unattached-worker".parse().unwrap()); + assert_eq!( + response(&f.api, hinted).await.0, + StatusCode::FORBIDDEN, + "hints cannot change the verified source into an unrestricted legacy Worker" + ); + for field in ["subject_id", "modelSubject", "runtime_id", "worker_id"] { + let mut claimed = body.clone(); + claimed[field] = json!("self-claimed-identity"); + let (status, error) = response( + &f.api, + runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&claimed).unwrap(), + ), + ) + .await; + assert!(status.is_client_error(), "{field}: {status} {error}"); + } + let mut tampered = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap(), + ); + *tampered.body_mut() = Body::from(serde_json::to_vec(&activation("web")).unwrap()); + assert_eq!(response(&f.api, tampered).await.0, StatusCode::UNAUTHORIZED); + let forged_identity = RuntimeIdentityMaterial::generate(&f.source.runtime_id).unwrap(); + assert_eq!( + response( + &f.api, + runtime_source_request( + &forged_identity, + Some(&f.source.worker_id), + "POST", + &authorization_path(), + serde_json::to_vec(&body).unwrap() + ) + ) + .await + .0, + StatusCode::UNAUTHORIZED + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn only_owner_account_can_read_assign_shrink_and_revoke_subject_permissions() { + let f = Fixture::new().await; + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert!(body["permissions"].is_null()); + let original = f + .assign( + vec![activation("ticket"), create_permission()], + vec![activation("ticket")], + ) + .await; + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + body["permissions"], + serde_json::to_value(&original).unwrap() + ); + let foreign = seed_test_api_token(f.api.store.as_ref(), "foreign-policy-account"); + let update = json!({"operation_id":"untrusted-admin","expected_change_id":original.change_id, + "execution":[],"delegation":[]}); + for method in ["GET", "POST"] { + let bytes = if method == "POST" { + serde_json::to_vec(&update).unwrap() + } else { + vec![] + }; + for source in [Some(f.source.worker_id.as_str()), None] { + let (status, body) = response( + &f.api, + runtime_source_request( + &f.identity, + source, + method, + &f.policy_path(), + bytes.clone(), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "{method} Runtime/Workspace source {source:?}: {body}" + ); + } + let (status, body) = response( + &f.api, + human_request( + &foreign, + method, + &f.policy_path(), + (method == "POST").then(|| update.clone()), + ), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "foreign account {method}: {body}" + ); + } + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap(), + Some(original.clone()) + ); + let shrunk = f.assign(vec![activation("ticket")], vec![]).await; + assert_ne!(shrunk.change_id, original.change_id); + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::FORBIDDEN + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update)), + ) + .await; + assert_eq!( + status, + StatusCode::CONFLICT, + "stale policy replacement: {body}" + ); + let revoked = f.assign(vec![], vec![]).await; + assert!(revoked.execution.is_empty() && revoked.delegation.is_empty()); + assert_eq!( + f.check(&f.source, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); +} + +#[derive(Clone)] +struct UnusedModel; +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for UnusedModel { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + _: agen::llm_client::Request, + ) -> std::result::Result + { + panic!("authorization tests must not execute a model") + } +} +async fn ticket_worker_host_with_service( + root: &Path, + client: Arc, +) -> ( + worker::Worker, + bool, + Option>, +) { + let manifest = worker::WorkerManifest::from_toml( + r#" + [worker] + name = "authorization-tools" + [model] + scheme = "anthropic" + model_id = "unused" + [engine] + [scope] + allow = [] + [feature.workdir_catalog] + enabled = false + [feature.ticket] + enabled = true + authoring = true + "#, + ) + .unwrap(); + let engine = + agen::Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( + UnusedModel, + ); + let mut host = worker::Worker::new( + manifest, + engine, + session_store::FsStore::new(root).unwrap(), + worker::WorkerWorkspaceContext::with_client( + Some(worker::WorkspaceId::new(TEST_WORKSPACE_ID).unwrap()), + client.clone(), + ), + worker::WorkerFilesystemAuthority::None, + manifest::Scope::empty(), + ) + .await + .unwrap(); + let report = host.install_features(worker::feature::FeatureRegistryBuilder::new().with_module( + TicketFeature::new(client, TicketFeatureAccess::workspace_authoring()), + )); + let installed = !report.has_errors(); + let service = if installed { + Some( + report + .services + .service::( + &worker::feature::ServiceId::builtin( + worker::feature::builtin::ticket::TICKET_SERVICE_ID, + ), + ) + .unwrap(), + ) + } else { + None + }; + (host, installed, service) +} +async fn ticket_worker_host( + root: &Path, + client: Arc, +) -> (worker::Worker, bool) { + let (host, installed, _) = ticket_worker_host_with_service(root, client).await; + (host, installed) +} +async fn ticket_tool_host(root: &Path, client: Arc) -> (bool, Option>) { + let (host, installed) = ticket_worker_host(root, client).await; + let server = host.engine().tool_server_handle(); + server.flush_pending(); + let tool = server.get_tool("TicketCreate").map(|(_, tool)| tool); + if !installed { + assert!( + tool.is_none(), + "denied activation must not partially install TicketCreate" + ); + } + (installed, tool) +} +fn ticket_posts(client: &RouterClient) -> usize { + client + .requests + .lock() + .unwrap() + .iter() + .filter(|(method, path)| { + method == "POST" && path == &format!("/api/w/{TEST_WORKSPACE_ID}/tickets") + }) + .count() +} +fn value(output: ToolOutput) -> Value { + serde_json::from_str(output.content.as_deref().unwrap()).unwrap() +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn worker_feature_activation_is_distinct_from_ticket_create_and_revocation_precedes_backend_call() + { + let f = Fixture::new().await; + let client = f.client(&f.source); + for (name, permissions, installed) in [ + ("unset", vec![], false), + ("operation-only", vec![create_permission()], false), + ("activation-only", vec![activation("ticket")], true), + ] { + f.assign(permissions, vec![]).await; + let (actual, tool) = ticket_tool_host(&f._dir.path().join(name), client.clone()).await; + assert_eq!(actual, installed, "{name}"); + if let Some(tool) = tool { + let result = tool + .execute( + r#"{"title":"must not reach TicketBackend"}"#, + ToolExecutionContext::direct(), + ) + .await; + assert!(result.is_err(), "activation alone cannot create a Ticket"); + assert_eq!( + ticket_posts(&client), + 0, + "denial must precede TicketBackend HTTP call" + ); + } + } + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + let (installed, tool) = ticket_tool_host(&f._dir.path().join("allowed"), client.clone()).await; + assert!(installed); + let tool = tool.unwrap(); + let created = value( + tool.execute( + r#"{"title":"Real authorized Ticket"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + assert_eq!(ticket_posts(&client), 1); + let backend = browser_ticket_backend(&f.api).unwrap(); + let records = backend.list(Default::default()).unwrap(); + assert_eq!( + records.len(), + 1, + "genuine SQLite Ticket backend result: {created}" + ); + f.assign(vec![activation("ticket")], vec![]).await; + let result = tool + .execute( + r#"{"title":"revoked after install"}"#, + ToolExecutionContext::direct(), + ) + .await; + assert!(result.is_err()); + assert_eq!( + ticket_posts(&client), + 1, + "old installed Tool must recheck before backend call" + ); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn explicit_delegation_creates_workdirless_ticketless_editor_and_replays_one_worker_and_grant() + { + let f = Fixture::new().await; + let grant_permissions = vec![activation("ticket"), create_permission()]; + let policy = f.assign(vec![], grant_permissions.clone()).await; + assert_eq!( + f.check(&f.source, &create_permission()).await.0, + StatusCode::FORBIDDEN, + "delegation ceiling does not activate operations on the body" + ); + let child = f + .child("ordinary-editor-once", grant_permissions.clone()) + .await; + let requests = f.runtime.spawn_requests(); + let source_manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle( + requests[0].resolved_config_bundle.as_ref().unwrap(), + "project:authority-source", + ) + .unwrap(); + assert!(!source_manifest.scope.allow.is_empty()); + assert!( + source_manifest + .scope + .allow + .iter() + .all(|rule| rule.permission == manifest::Permission::Read) + ); + let editor_manifest = crate::profile_settings::resolve_profile_manifest_from_config_bundle( + requests[1].resolved_config_bundle.as_ref().unwrap(), + "builtin:coder", + ) + .unwrap(); + assert!( + editor_manifest + .scope + .allow + .iter() + .any(|rule| rule.permission == manifest::Permission::Write), + "editor Profile resolution is independent of the parent's read-only Profile" + ); + assert!( + f.api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &child) + .unwrap() + .is_empty() + ); + assert!( + f.api + .store + .require_current_worker_singleton_owner(TEST_WORKSPACE_ID, &child) + .unwrap() + .is_none() + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let grant = store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + assert_eq!(grant.subject_id, f.subject); + assert_eq!(grant.origin_runtime_id, f.source.runtime_id); + assert_eq!(grant.origin_worker_id, f.source.worker_id); + assert_eq!(grant.source_change_id, policy.change_id); + assert_eq!(grant.permissions, grant_permissions); + assert!(grant.redelegation.is_empty()); + assert_eq!(grant.profile, "builtin:coder"); + assert!(!grant.profile_digest.is_empty()); + let (status, replay) = f + .spawn( + &f.source, + f.child_request("ordinary-editor-once", grant_permissions.clone()), + ) + .await; + assert_eq!(status, StatusCode::OK, "{replay}"); + assert_eq!(replay["worker_id"], child.worker_id); + let worker_identities = f + .runtime + .workers + .lock() + .unwrap() + .iter() + .map(|summary| { + ( + summary.worker.runtime_id.clone(), + summary.worker.worker_id.clone(), + ) + }) + .collect::>(); + assert_eq!( + worker_identities.len(), + 2, + "Runtime may receive replayed creation, but only one source and one reserved editor identity exist" + ); + let replay_calls = f.runtime.spawn_requests().len(); + assert_eq!( + store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(grant) + ); + let mut changed = f.child_request("ordinary-editor-once", grant_permissions); + changed["display_name"] = json!("changed replay payload"); + let (status, body) = f.spawn(&f.source, changed).await; + assert!( + !status.is_success(), + "changed payload must not be adopted: {body}" + ); + assert!( + body["message"] + .as_str() + .unwrap() + .contains("workspace_worker_allocation_conflict"), + "known allocation refusal, not unknown Runtime outcome: {status} {body}" + ); + assert_eq!(f.runtime.spawn_requests().len(), replay_calls); + assert_eq!( + f.check(&child, &create_permission()).await.0, + StatusCode::OK + ); + let (status, body) = f + .spawn( + &child, + f.child_request("undelegatable-grandchild", vec![activation("ticket")]), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "grant has no redelegation: {body}" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn delegated_creation_rejects_excess_permissions_and_body_identity_before_runtime_spawn() { + let f = Fixture::new().await; + f.assign(vec![], vec![activation("ticket")]).await; + let mut singleton = f.child_request("singleton-escalation", vec![activation("ticket")]); + singleton["singleton_key"] = json!("model-chosen-singleton"); + let mut body_attachment = f.child_request("subject-escalation", vec![activation("ticket")]); + body_attachment["feature_connections"] = json!({"subjektiv":{"subject_id":f.subject}}); + let mut spoof = f.child_request("origin-escalation", vec![activation("ticket")]); + spoof["execution_grant"]["subject_id"] = json!(f.subject); + for request in [ + singleton, + body_attachment, + f.child_request("ceiling-escalation", vec![create_permission()]), + spoof, + ] { + let (status, body) = f.spawn(&f.source, request).await; + assert!(status.is_client_error(), "{status}: {body}"); + } + assert_eq!( + f.runtime.spawn_requests().len(), + 1, + "only the genuine body reached Runtime execution" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn restored_child_cannot_refresh_revoked_policy_and_released_origin_never_falls_back_to_legacy() + { + let f = Fixture::new().await; + f.assign(vec![], vec![activation("ticket")]).await; + let child = f.child("revocable-child", vec![activation("ticket")]).await; + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::OK + ); + let before = open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + f.assign(vec![], vec![]).await; + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); + let observation = f + .api + .runtime + .worker(&child) + .unwrap() + .restore_observation_token + .unwrap(); + let restore_path = format!( + "/api/w/{TEST_WORKSPACE_ID}/runtimes/{}/workers/{}/restore", + child.runtime_id, child.worker_id + ); + let (status, body) = response(&f.api, human_request(&f.owner, "POST", &restore_path, + Some(json!({"expected_observation_token":observation,"request_id":"restore-revoked-child"})))).await; + assert_eq!(status, StatusCode::OK, "{body}"); + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(before) + ); + // Regranting identical permissions changes the assignment identity. Old + // grants remain revoked rather than refreshing during restore/reconnect. + f.assign(vec![], vec![activation("ticket")]).await; + assert_eq!( + f.check(&child, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); + let current = f + .child("current-before-origin-release", vec![activation("ticket")]) + .await; + assert_eq!( + f.check(¤t, &activation("ticket")).await.0, + StatusCode::OK + ); + f.release_source_lease(); + assert_eq!( + f.check(¤t, &activation("ticket")).await.0, + StatusCode::CONFLICT, + "the captured source lease is no longer current" + ); + assert_eq!( + f.check(&f.source, &activation("ticket")).await.0, + StatusCode::UNAUTHORIZED, + "released body fails signed-source authentication instead of falling through to legacy compatibility" + ); + let (status, _) = f + .spawn( + &f.source, + f.child_request("released-origin-spawn", vec![activation("ticket")]), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn native_wip_ticket_create_uses_same_signed_authorization_before_real_backend() { + let f = Fixture::new().await; + f.assign(vec![activation("ticket")], vec![]).await; + let client = f.client(&f.source); + let (mut host, installed) = + ticket_worker_host(&f._dir.path().join("wip-host"), client.clone()).await; + assert!(installed); + let mut mounts = worker::wip::WipMountRegistry::new(); + let route = mounts.allocate_namespace("ticket", "tickets").unwrap(); + worker::feature::builtin::ticket::mount_workspace_http_ticket_wip( + &mut mounts, + client.clone(), + TicketFeatureAccess::workspace_authoring(), + None, + &route, + ) + .unwrap(); + worker::wip::install_wip_mode_with_mounts( + host.engine_mut(), + None, + "subject-authorization-wip".into(), + mounts, + ) + .unwrap(); + let server = host.engine().tool_server_handle(); + server.flush_pending(); + assert!( + server.get_tool("TicketCreate").is_none(), + "native surface replaces compatibility Tool" + ); + let inspect = server.get_tool("Inspect").unwrap().1; + let invoke = server.get_tool("Invoke").unwrap().1; + let inspected = value( + inspect + .execute( + r#"{"path":"/tickets","refresh":true}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(), + ); + let reference = inspected["interfaces"] + .as_array() + .unwrap() + .iter() + .find(|interface| interface["reference"]["name"] == "yoi.ticket/collection/v1") + .expect("native Ticket collection interface")["reference"] + .clone(); + let args = json!({"path":"/tickets","interface":reference,"operation":"create","arguments":{"title":"Native authorized Ticket"}}).to_string(); + assert!( + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 0, + "WIP denial must not call TicketBackend HTTP" + ); + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!(ticket_posts(&client), 1); + let backend = browser_ticket_backend(&f.api).unwrap(); + let tickets = backend.list(Default::default()).unwrap(); + assert_eq!(tickets.len(), 1); + f.assign(vec![activation("ticket")], vec![]).await; + assert!( + invoke + .execute(&args, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 1, + "WIP does not cache an old operation allow" + ); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn policy_replacement_reports_interruption_evidence_but_exact_replay_does_not_interrupt_again() + { + let f = Fixture::new().await; + let update = json!({"operation_id":"policy-interruption-once","expected_change_id":null, + "execution":[activation("ticket")],"delegation":[]}); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update.clone())), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let committed: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert_eq!(committed.interruptions.len(), 1); + let evidence = &committed.interruptions[0]; + assert_eq!(evidence.runtime_id, f.source.runtime_id); + assert_eq!(evidence.worker_id, f.source.worker_id); + assert!( + evidence.cancellation_accepted, + "execution fixture accepts cancel, not proof a turn stopped" + ); + assert!( + evidence.backend_command_sessions_closed, + "cleanup evidence is separate from cancel acceptance" + ); + assert_eq!( + open_subjektiv_store(&f.api) + .unwrap() + .subject_permissions(&f.subject) + .unwrap(), + Some(committed.permissions.clone()) + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "POST", &f.policy_path(), Some(update)), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let replay: server_api::SubjectPermissionsMutationResponse = + serde_json::from_value(body).unwrap(); + assert_eq!(replay.permissions, committed.permissions); + assert!( + replay.interruptions.is_empty(), + "receipt replay must not request a new interruption" + ); + let (status, body) = response( + &f.api, + human_request(&f.owner, "GET", &f.policy_path(), None), + ) + .await; + assert_eq!(status, StatusCode::OK); + let read: server_api::SubjectPermissionsResponse = serde_json::from_value(body).unwrap(); + assert_eq!(read.permissions, Some(replay.permissions)); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn grandchild_grant_uses_immediate_parent_subset_not_root_subject_ceiling() { + let f = Fixture::new().await; + f.assign(vec![], vec![activation("ticket"), create_permission()]) + .await; + let mut request = f.child_request( + "redelegating-parent", + vec![activation("ticket"), create_permission()], + ); + request["execution_grant"]["redelegation"] = json!([activation("ticket")]); + let (status, body) = f.spawn(&f.source, request).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let parent = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let (status, body) = f + .spawn( + &parent, + f.child_request("allowed-grandchild", vec![activation("ticket")]), + ) + .await; + assert_eq!(status, StatusCode::OK, "{body}"); + let grandchild = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let grant = open_subjektiv_store(&f.api) + .unwrap() + .worker_execution_grant(&grandchild.runtime_id, &grandchild.worker_id) + .unwrap() + .unwrap(); + assert_eq!( + grant.origin_worker_id, parent.worker_id, + "keep immediate lineage instead of flattening to the body" + ); + assert_eq!( + f.check(&grandchild, &activation("ticket")).await.0, + StatusCode::OK + ); + assert_eq!( + f.check(&grandchild, &create_permission()).await.0, + StatusCode::FORBIDDEN + ); + let calls = f.runtime.spawn_requests().len(); + let (status, body) = f + .spawn( + &parent, + f.child_request("root-ceiling-escalation", vec![create_permission()]), + ) + .await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "root permits create but immediate parent cannot pass it: {body}" + ); + assert_eq!(f.runtime.spawn_requests().len(), calls); + f.assign(vec![], vec![]).await; + assert_eq!( + f.check(&grandchild, &activation("ticket")).await.0, + StatusCode::FORBIDDEN + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn same_profile_name_with_wider_resolved_scope_cannot_rebind_existing_operation() { + let f = Fixture::new().await; + f.assign(vec![], vec![activation("ticket")]).await; + let mut request = f.child_request("profile-bound-once", vec![activation("ticket")]); + request["profile"] = json!("project:authority-editor"); + let (status, body) = f.spawn(&f.source, request.clone()).await; + assert_eq!(status, StatusCode::OK, "{body}"); + let child = RuntimeWorkerRef::new( + body["runtime_id"].as_str().unwrap(), + body["worker_id"].as_str().unwrap(), + ); + let store = open_subjektiv_store(&f.api).unwrap(); + let original = store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap() + .unwrap(); + let calls = f.runtime.spawn_requests().len(); + install_subject_profiles(&f.api, "workspace_write"); + let (status, body) = f.spawn(&f.source, request).await; + assert!( + !status.is_success(), + "same-name Profile must not replace the operation's resolved scope: {body}" + ); + assert_eq!( + store + .worker_execution_grant(&child.runtime_id, &child.worker_id) + .unwrap(), + Some(original) + ); + assert_eq!( + f.runtime.spawn_requests().len(), + calls, + "changed bundle must fail before Runtime creation" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn installed_ticket_service_and_raw_backend_recheck_current_signed_source() { + let f = Fixture::new().await; + f.assign(vec![activation("ticket")], vec![]).await; + let client = f.client(&f.source); + let (_host, installed, service) = + ticket_worker_host_with_service(&f._dir.path().join("service-host"), client.clone()).await; + assert!(installed); + let service = service.unwrap(); + let denied = service.clone(); + assert!( + tokio::task::spawn_blocking(move || denied.create(NewTicket::new("Service denied"))) + .await + .unwrap() + .is_err() + ); + assert_eq!( + ticket_posts(&client), + 0, + "service denial happens before Ticket backend HTTP" + ); + // A real unattached Worker has the legacy contract; hints cannot substitute + // that principal for the signed Subject body. + let Json(legacy) = create_workspace_worker( + State(f.api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: f.source.runtime_id.clone(), + display_name: "Raw hint target".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: vec![], + workdir_attachments: vec![], + feature_connections: Default::default(), + control_operation_id: None, + execution_grant: None, + }), + ) + .await + .unwrap(); + // Bypassing the cooperative Feature check cannot bypass the Server's recheck. + let mut raw = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets"), + serde_json::to_vec(&NewTicket::new("Raw backend denied")).unwrap(), + ); + raw.headers_mut() + .insert("x-yoi-runtime-id", legacy.runtime_id.parse().unwrap()); + raw.headers_mut() + .insert("x-yoi-worker-id", legacy.worker_id.parse().unwrap()); + let (status, body) = response(&f.api, raw).await; + assert_eq!( + status, + StatusCode::FORBIDDEN, + "raw source hints cannot become another principal: {body}" + ); + assert!( + browser_ticket_backend(&f.api) + .unwrap() + .list(Default::default()) + .unwrap() + .is_empty() + ); + f.assign(vec![activation("ticket"), create_permission()], vec![]) + .await; + let allowed = service.clone(); + let created = + tokio::task::spawn_blocking(move || allowed.create(NewTicket::new("Service authorized"))) + .await + .unwrap() + .unwrap(); + let backend = browser_ticket_backend(&f.api).unwrap(); + assert_eq!( + backend.show(created.id.into()).unwrap().meta.title, + "Service authorized" + ); + assert_eq!(ticket_posts(&client), 1); + f.assign(vec![activation("ticket")], vec![]).await; + assert!( + tokio::task::spawn_blocking(move || service.create(NewTicket::new("Revoked service"))) + .await + .unwrap() + .is_err() + ); + assert_eq!(ticket_posts(&client), 1); + let raw = runtime_source_request( + &f.identity, + Some(&f.source.worker_id), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets"), + serde_json::to_vec(&NewTicket::new("Raw revoked backend")).unwrap(), + ); + assert_eq!(response(&f.api, raw).await.0, StatusCode::FORBIDDEN); + assert_eq!(backend.list(Default::default()).unwrap().len(), 1); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn signed_host_scope_guards_real_local_read_write_and_revocation_before_effects() { + use workdir::{ReadRequest, WorkdirPath, WriteRequest}; + let f = Fixture::new().await; + let resource = PermissionResource::Workdir { + working_directory_id: "fixture-local-attachment".into(), + path: "src".into(), + recursive: true, + }; + let permissions = vec![ + ScopedPermission { + operation: "filesystem.read".into(), + resource: resource.clone(), + }, + ScopedPermission { + operation: "filesystem.write".into(), + resource: resource.clone(), + }, + ]; + f.assign(vec![], permissions.clone()).await; + let child = f.child("local-resource-child", permissions).await; + // This independently exercises real local operations; the creation Runtime + // fixture never executes the editor. The attachment ID is explicit fixture + // input, not a claim that a real Runtime materialized this local directory. + let root = tempfile::tempdir().unwrap(); + fs::create_dir(root.path().join("src")).unwrap(); + fs::write(root.path().join("src/source.txt"), b"source").unwrap(); + fs::write(root.path().join("private.txt"), b"secret").unwrap(); + let client = f.client(&child); + let session = worker::authorized_workdir::authorized_workdir_session( + Arc::new(workdir::LocalWorkdirSession::new( + manifest::Scope::writable(root.path()).unwrap(), + root.path().to_path_buf(), + )), + client, + "fixture-local-attachment".into(), + ); + let read = |path: &str| ReadRequest { + path: WorkdirPath::new(path).unwrap(), + offset: 0, + limit: 100, + max_bytes: 4096, + }; + assert_eq!( + session.read(read("src/source.txt")).await.unwrap().bytes, + b"source" + ); + assert!(session.read(read("private.txt")).await.is_err()); + session + .write(WriteRequest { + path: WorkdirPath::new("src/created.txt").unwrap(), + content: b"created".to_vec(), + expected_hash: None, + }) + .await + .unwrap(); + assert_eq!( + fs::read(root.path().join("src/created.txt")).unwrap(), + b"created" + ); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("denied.txt").unwrap(), + content: b"bad".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert!(!root.path().join("denied.txt").exists()); + #[cfg(unix)] + { + std::os::unix::fs::symlink( + root.path().join("private.txt"), + root.path().join("src/link"), + ) + .unwrap(); + assert!( + session.read(read("src/link")).await.is_err(), + "Host's granted src prefix must not follow a symlink outside src" + ); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/link").unwrap(), + content: b"escaped".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert_eq!( + fs::read(root.path().join("private.txt")).unwrap(), + b"secret" + ); + } + let (_, scope) = f + .check( + &child, + &ScopedPermission { + operation: "filesystem.read".into(), + resource: PermissionResource::Workdir { + working_directory_id: "fixture-local-attachment".into(), + path: "src/source.txt".into(), + recursive: false, + }, + }, + ) + .await; + assert_eq!( + scope, + json!({"scope":resource}), + "response preserves granted prefix rather than turning requested symlink path into a new root" + ); + f.assign(vec![], vec![]).await; + assert!(session.read(read("src/source.txt")).await.is_err()); + assert!( + session + .write(WriteRequest { + path: WorkdirPath::new("src/created.txt").unwrap(), + content: b"revoked".to_vec(), + expected_hash: None + }) + .await + .is_err() + ); + assert_eq!( + fs::read(root.path().join("src/created.txt")).unwrap(), + b"created" + ); + session.close().await.unwrap(); +} diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index 3e1ff2ddf..03a3dba16 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -527,6 +527,7 @@ async fn subjektiv_job_legacy_cutover_waits_foreground_then_removes_only_owned_c resolved_worker_observation_enabled: false, resolved_worker_observation_grants: vec![], resolved_control_operation: None, + resolved_execution_grant: None, }, ) .unwrap() diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs index c3aca207b..3e7e24000 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_connected_tests.rs @@ -260,7 +260,7 @@ async fn connected_create(api: &WorkspaceApi, subject: &str, name: &str) -> Runt workdir_attachments: vec![], feature_connections: server_api::WorkspaceWorkerFeatureConnectionsRequest { subjektiv: Some(server_api::SubjektivWorkerConnectionRequest { subject_id: subject.into() }), - }, control_operation_id: None, + }, control_operation_id: None, execution_grant: None, }), ).await.unwrap(); RuntimeWorkerRef::new(created.runtime_id, created.worker_id) @@ -386,6 +386,56 @@ async fn backend_real_runtime_journal_release_and_next_body_persisted_startup() let subject = store .create_subject(crate::subjektiv::SubjectRole::new("connected-handoff").unwrap()) .unwrap(); + let owner = + seed_test_workspace_owner_token(api.store.as_ref(), "connected-journal-owner").await; + // This fixture exercises Journal continuity, not unset-policy refusal. Assign + // the companion's exact activation keys through authenticated administration; + // no filesystem, shell or delegation permission is granted to its body. + let update = server_api::authorization::SubjectPermissionsUpdate { + operation_id: "connected-journal-activation".into(), + expected_change_id: None, + execution: [ + "task", + "subjektiv", + "web", + "image", + "sub_worker", + "worker", + "workspace_worker_discovery", + "objective", + "manage_workdir", + "workdir_catalog", + "drive", + "ticket", + ] + .into_iter() + .map(|name| server_api::authorization::ScopedPermission { + operation: name.into(), + resource: server_api::authorization::PermissionResource::Feature, + }) + .collect(), + delegation: Vec::new(), + }; + let response = build_router(api.clone()) + .oneshot( + axum::http::Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/subjektiv/subjects/{}/permissions", + subject.id + )) + .header("authorization", format!("Bearer {owner}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), 2 * 1024 * 1024) + .await + .unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); let old = connected_create(&api, &subject.id, "connected-old-body").await; let old_attr = store .subject_session_attributions(&subject.id) diff --git a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs index 8f83a29e0..3288f6610 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_journal_tests.rs @@ -1686,6 +1686,7 @@ impl SubjectRemovalFixture { }), }, control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index 304e18160..8d7ef1592 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -1032,6 +1032,7 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }; api.validate_worker_spawn_repository_scope(EMBEDDED_WORKER_RUNTIME_ID, &mut request) .unwrap(); @@ -1580,6 +1581,7 @@ async fn mr_open_retains_source_before_first_review_and_observed_result() { }], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index af3bf4ab2..f92b6d172 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -200,6 +200,7 @@ impl Fixture { workdir_attachments: vec![], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -767,6 +768,7 @@ async fn worker_removal_preserves_drive_reference_for_explicitly_granted_replace workdir_attachments: vec![], feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/server/workdir_authorization.rs b/crates/workspace-server/src/server/workdir_authorization.rs new file mode 100644 index 000000000..d6ddb66de --- /dev/null +++ b/crates/workspace-server/src/server/workdir_authorization.rs @@ -0,0 +1,537 @@ +//! Subject resource checks at the Workdir provider boundary, in addition to existing ceilings. +//! +//! Paths here are attachment-relative lexical resources. Providers still own resolved-path, +//! symlink, capability and checkout validation; this helper never substitutes for those checks. + +use server_api::authorization::{PermissionResource, ScopedPermission}; +use workdir::{ + WorkdirPath, WorkdirSessionCapabilities, WorkdirToolScopePermission, + http::WorkdirSessionOperation, +}; + +use super::{ApiError, ApiResult, Error, RuntimeWorkerRef, WorkspaceApi}; +use crate::store::WorkerWorkdirLinkRecord; + +const READ: &str = "filesystem.read"; +const WRITE: &str = "filesystem.write"; +const COMMAND: &str = "bash.execute"; + +pub(super) fn authorize( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + link: &WorkerWorkdirLinkRecord, + operation: &WorkdirSessionOperation, +) -> ApiResult<()> { + // These are reconciliation/termination of an already registered effect, not execution. + // Keep the existing worker/attachment/opaque-handle ownership checks on these routes. + if is_command_reconciliation(operation) { + return Ok(()); + } + let permissions = super::subject_authorization::worker_permissions(api, worker)?; + authorize_permissions(permissions.as_deref(), &link.workdir_id, operation) +} + +fn is_command_reconciliation(operation: &WorkdirSessionOperation) -> bool { + matches!( + operation, + WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) + ) +} + +fn denied(message: &str) -> ApiError { + Error::WorkspacePermissionDenied(format!("Workdir Subject authorization: {message}")).into() +} + +fn resource( + working_directory_id: &str, + operation: &str, + path: &WorkdirPath, + recursive: bool, +) -> ApiResult { + // FsPath also accepts explicitly scoped absolute paths. Subject resources here are + // attachment-relative only, even if the provider separately supports absolute grants. + let normalized = WorkdirPath::new(path.as_str()) + .map_err(|_| denied("operation path must be attachment-relative without escape"))?; + let permission = ScopedPermission { + operation: operation.into(), + resource: PermissionResource::Workdir { + working_directory_id: working_directory_id.into(), + path: if normalized.is_root() { + ".".into() + } else { + normalized.as_str().into() + }, + recursive, + }, + }; + permission + .validate() + .map_err(|_| denied("invalid normalized operation resource"))?; + Ok(permission) +} + +fn require( + permissions: &[ScopedPermission], + working_directory_id: &str, + operation: &str, + path: &WorkdirPath, + recursive: bool, +) -> ApiResult<()> { + let requested = resource(working_directory_id, operation, path, recursive)?; + if permissions.iter().any(|granted| granted.covers(&requested)) { + Ok(()) + } else { + Err(denied("operation or resource is outside the current grant")) + } +} + +fn authorize_permissions( + permissions: Option<&[ScopedPermission]>, + working_directory_id: &str, + operation: &WorkdirSessionOperation, +) -> ApiResult<()> { + if is_command_reconciliation(operation) { + return Ok(()); + } + let Some(permissions) = permissions else { + // Non-Subject Workers retain the existing provider/Profile/attachment ceilings. + return Ok(()); + }; + let check = |operation, path: &WorkdirPath, recursive| { + require( + permissions, + working_directory_id, + operation, + path, + recursive, + ) + }; + match operation { + WorkdirSessionOperation::Stat(request) => check(READ, &request.path, false), + WorkdirSessionOperation::Read(request) => check(READ, &request.path, false), + WorkdirSessionOperation::ReadBytes(request) => check(READ, &request.path, false), + WorkdirSessionOperation::Write(request) => check(WRITE, &request.path, false), + WorkdirSessionOperation::Edit(request) => check(WRITE, &request.path, false), + WorkdirSessionOperation::List(request) => { + // Directory enumeration accesses children, not just the directory inode. + check(READ, &request.path, true)?; + if let Some(cursor) = &request.after { + check(READ, &cursor.path, false)?; + } + Ok(()) + } + WorkdirSessionOperation::Glob(request) => check(READ, &request.path, true), + WorkdirSessionOperation::Grep(request) => check(READ, &request.path, true), + WorkdirSessionOperation::AuthorizeScope(request) => check( + match request.permission { + WorkdirToolScopePermission::Read => READ, + WorkdirToolScopePermission::Write => WRITE, + }, + &request.path, + false, + ), + WorkdirSessionOperation::CommandStart(request) => { + // 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(_) => { + // Output may contain anything the original shell read. Unlike status/cancel, + // retrieving its contents requires a current whole-attachment command grant. + check(COMMAND, &WorkdirPath::root(), true) + } + WorkdirSessionOperation::CommandCancel(_) | WorkdirSessionOperation::CommandStatus(_) => { + Ok(()) + } + // These contracts have additional targets, scopes or provider-resolved metadata. + // Deny explicitly until all their accesses can be mapped to scoped resources. + WorkdirSessionOperation::ScopeRulesOverlap(_) + | WorkdirSessionOperation::CheckoutObserve(_) + | WorkdirSessionOperation::CheckoutExecute(_) + | WorkdirSessionOperation::CheckoutSearch(_) => { + Err(denied("operation has no scoped Subject resource mapping")) + } + } +} + +/// Attenuate coarse attachment capabilities, without converting a path grant into root scope. +/// Per-operation authorization above remains required, including for recursive searches. +pub(super) fn intersect_capabilities( + permissions: &[ScopedPermission], + working_directory_id: &str, + ceiling: WorkdirSessionCapabilities, +) -> WorkdirSessionCapabilities { + let has_path_grant = |operation: &str| { + permissions.iter().any(|grant| { + grant.operation == operation + && grant.validate().is_ok() + && matches!(&grant.resource, PermissionResource::Workdir { + working_directory_id: id, .. + } if id == working_directory_id) + }) + }; + let mut allowed = WorkdirSessionCapabilities::EMPTY; + if has_path_grant(READ) { + allowed = allowed.union(WorkdirSessionCapabilities::READ_ONLY); + } + if has_path_grant(WRITE) { + allowed = allowed.union(WorkdirSessionCapabilities::WRITE_ONLY); + } + if require( + permissions, + working_directory_id, + COMMAND, + &WorkdirPath::root(), + true, + ) + .is_ok() + { + allowed = allowed.union(WorkdirSessionCapabilities::COMMAND_ONLY); + } + ceiling.intersection(allowed) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use workdir::{ + CommandHandle, CommandOutputRequest, CommandRequest, EditRequest, GlobRequest, GrepRequest, + ListRequest, ReadBytesRequest, ReadRequest, StatRequest, WriteRequest, + }; + + fn path(value: &str) -> WorkdirPath { + WorkdirPath::new(value).unwrap() + } + + fn grant(operation: &str, target: &str, recursive: bool) -> ScopedPermission { + resource("wd", operation, &path(target), recursive).unwrap() + } + + fn allowed(grants: &[ScopedPermission], operation: &WorkdirSessionOperation) -> bool { + authorize_permissions(Some(grants), "wd", operation).is_ok() + } + + fn read(target: &str) -> WorkdirSessionOperation { + WorkdirSessionOperation::Read(ReadRequest { + path: path(target), + offset: 0, + limit: 10, + max_bytes: 1024, + }) + } + + fn command(cwd: &str) -> WorkdirSessionOperation { + WorkdirSessionOperation::CommandStart(CommandRequest { + command: "cd ..; cat secret".into(), + timeout_secs: 1, + output_limit: 1024, + cwd: path(cwd), + spill_dir: None, + tool_call_id: None, + }) + } + + #[test] + fn real_filesystem_operations_require_exact_operation_and_attachment_scope() { + let read_grant = grant(READ, "src", true); + let write_grant = grant(WRITE, "src", true); + let reads = [ + read("src/a"), + WorkdirSessionOperation::Stat(StatRequest { + path: path("src/a"), + }), + WorkdirSessionOperation::ReadBytes(ReadBytesRequest { + path: path("src/a"), + offset: 0, + max_bytes: 10, + expected_hash: None, + }), + WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None, + }), + WorkdirSessionOperation::Glob(GlobRequest { + pattern: "**/*".into(), + path: path("src"), + limit: 10, + }), + WorkdirSessionOperation::Grep(GrepRequest { + pattern: "needle".into(), + path: path("src"), + glob: None, + file_type: None, + case_insensitive: false, + before_context: 0, + after_context: 0, + multiline: false, + output_mode: Default::default(), + limit: 10, + offset: 0, + }), + ]; + for operation in reads { + assert!( + allowed(std::slice::from_ref(&read_grant), &operation), + "{operation:?}" + ); + assert!( + !allowed(std::slice::from_ref(&write_grant), &operation), + "{operation:?}" + ); + assert!( + authorize_permissions( + Some(std::slice::from_ref(&read_grant)), + "other-wd", + &operation + ) + .is_err() + ); + } + let writes = [ + WorkdirSessionOperation::Write(WriteRequest { + path: path("src/a"), + content: vec![], + expected_hash: None, + }), + WorkdirSessionOperation::Edit(EditRequest { + path: path("src/a"), + old_string: "a".into(), + new_string: "b".into(), + replace_all: false, + expected_hash: [0; 32], + }), + ]; + for operation in writes { + assert!(allowed(std::slice::from_ref(&write_grant), &operation)); + assert!(!allowed(std::slice::from_ref(&read_grant), &operation)); + } + assert!(!allowed(&[read_grant], &read("src-other/a"))); + } + + #[test] + fn activation_workspace_and_parent_operation_names_do_not_grant_file_access() { + for permission in [ + ScopedPermission { + operation: "filesystem".into(), + resource: PermissionResource::Feature, + }, + grant("filesystem", ".", true), + grant("filesystem.read.extra", ".", true), + ScopedPermission { + operation: READ.into(), + resource: PermissionResource::Workspace { + workspace_id: "ws".into(), + }, + }, + ] { + assert!(!allowed(&[permission], &read("src/a"))); + } + assert!(!allowed(&[], &read("src/a"))); + assert!(authorize_permissions(None, "wd", &read("src/a")).is_ok()); + } + + #[test] + fn nonrecursive_grant_cannot_enumerate_descendants_or_search() { + let grants = [grant(READ, "src", false)]; + assert!(allowed(&grants, &read("src"))); + assert!(!allowed(&grants, &read("src/a"))); + assert!(!allowed( + &grants, + &WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None + }) + )); + assert!(!allowed( + &grants, + &WorkdirSessionOperation::Glob(GlobRequest { + pattern: "a".into(), + path: path("src"), + limit: 10 + }) + )); + } + + #[test] + fn wire_paths_are_normalized_and_escape_is_denied_before_provider_dispatch() { + let grants = [grant(READ, "src", true)]; + for bad in [ + "/src/a", + "../src/a", + "src/../secret", + "src\\a", + "C:/src/a", + "src/\u{0}a", + ] { + let operation = serde_json::from_value::(json!({ + "operation": "stat", "request": {"path": bad} + })); + // Some escapes are already rejected by FsPath's wire boundary. Absolute + // provider paths and malformed resource names must also fail this boundary. + if let Ok(operation) = operation { + assert!(!allowed(&grants, &operation), "{bad:?}"); + } + } + let operation: WorkdirSessionOperation = serde_json::from_value(json!({ + "operation": "stat", "request": {"path": "./src//a"} + })) + .unwrap(); + assert!(allowed(&grants, &operation)); + } + + #[test] + fn scope_probes_and_list_cursors_cannot_access_outside_the_granted_resource() { + let grants = [grant(READ, "src", true)]; + for (target, permission, expected) in [ + ("src/a", WorkdirToolScopePermission::Read, true), + ("private/a", WorkdirToolScopePermission::Read, false), + ("src/a", WorkdirToolScopePermission::Write, false), + ] { + let operation = WorkdirSessionOperation::AuthorizeScope( + workdir::WorkdirScopeAuthorizationRequest { + rules: vec![], + path: path(target), + permission, + }, + ); + assert_eq!(allowed(&grants, &operation), expected); + } + let list = |cursor| { + WorkdirSessionOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: Some(workdir::ListCursor { + kind: workdir::EntryKind::File, + path: path(cursor), + }), + }) + }; + assert!(allowed(&grants, &list("src/a"))); + assert!(!allowed(&grants, &list("private/a"))); + } + + #[test] + fn arbitrary_shell_requires_recursive_attachment_root_not_cwd_scope() { + assert!(!allowed(&[grant(COMMAND, "src", true)], &command("src"))); + 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] + fn revocation_denies_new_execute_and_output_but_preserves_cancel_and_status() { + let handle = CommandHandle("owned-opaque-handle".into()); + assert!(allowed( + &[], + &WorkdirSessionOperation::CommandCancel(handle.clone()) + )); + assert!(allowed( + &[], + &WorkdirSessionOperation::CommandStatus(handle.clone()) + )); + assert!(!allowed(&[], &command("."))); + let output = WorkdirSessionOperation::CommandOutput(CommandOutputRequest { + handle, + cursor: 0, + limit: 10, + wait: false, + }); + assert!(!allowed(&[], &output)); + assert!(allowed(&[grant(COMMAND, ".", true)], &output)); + } + + #[test] + fn unsupported_checkout_and_scope_comparison_fail_closed_for_scoped_workers() { + let grants = [ + grant(READ, ".", true), + grant(WRITE, ".", true), + grant(COMMAND, ".", true), + ]; + let operations = [ + serde_json::from_value::(json!({ + "operation": "scope_rules_overlap", + "request": { + "left": {"target": "src", "permission": "read", "recursive": true}, + "right": {"target": "private", "permission": "read", "recursive": true} + } + })) + .unwrap(), + WorkdirSessionOperation::CheckoutObserve(path("src")), + WorkdirSessionOperation::CheckoutExecute(workdir::CheckoutRequest { + target: path("src"), + validator: vec![], + operation: workdir::CheckoutOperation::Create { + path: path("outside/a"), + content: vec![], + }, + }), + WorkdirSessionOperation::CheckoutSearch(workdir::CheckoutSearchRequest::new( + workdir::CheckoutSearchOperation::List(ListRequest { + path: path("src"), + limit: 10, + after: None, + }), + )), + ]; + for operation in operations { + assert!(!allowed(&grants, &operation)); + assert!(authorize_permissions(None, "wd", &operation).is_ok()); + } + } + + #[test] + fn readonly_delegation_attenuates_edit_capable_profile_without_losing_narrow_read_scope() { + let grants = [grant(READ, "src", true)]; + assert_eq!( + intersect_capabilities(&grants, "wd", WorkdirSessionCapabilities::ALL), + WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + intersect_capabilities(&grants, "other", WorkdirSessionCapabilities::ALL), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(WRITE, ".", true)], + "wd", + WorkdirSessionCapabilities::READ_ONLY + ), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(COMMAND, "src", true)], + "wd", + WorkdirSessionCapabilities::ALL + ), + WorkdirSessionCapabilities::EMPTY + ); + assert_eq!( + intersect_capabilities( + &[grant(COMMAND, ".", true)], + "wd", + WorkdirSessionCapabilities::READ_WRITE + ), + WorkdirSessionCapabilities::EMPTY + ); + } +} diff --git a/crates/workspace-server/src/server_assignment_start_tests.rs b/crates/workspace-server/src/server_assignment_start_tests.rs index e4f7656fe..fa1b80235 100644 --- a/crates/workspace-server/src/server_assignment_start_tests.rs +++ b/crates/workspace-server/src/server_assignment_start_tests.rs @@ -53,6 +53,7 @@ async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntim resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ); summary.tags = vec!["orchestrator".into(), "singleton".into()]; diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs index 275bf9ff7..a6b3740e9 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -59,6 +59,7 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await @@ -794,6 +795,7 @@ async fn tracking_fixture( resolved_memory_settings: None, resolved_subjektiv_attached: false, resolved_control_operation: None, + resolved_execution_grant: None, }, ); summary.tags = vec!["orchestrator".into(), "singleton".into()]; diff --git a/crates/workspace-server/src/server_ticket_tools_backend_tests.rs b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs index 637d44a5d..662bc03b0 100644 --- a/crates/workspace-server/src/server_ticket_tools_backend_tests.rs +++ b/crates/workspace-server/src/server_ticket_tools_backend_tests.rs @@ -14,7 +14,7 @@ use worker::{ struct TicketRouterClient { api: WorkspaceApi, identity: Arc, - source: RuntimeWorkerRef, + source: Mutex, runtime: tokio::runtime::Handle, lose_next_response: AtomicBool, requests: Mutex>, @@ -45,9 +45,10 @@ impl WorkspaceClient for TicketRouterClient { WorkspaceRequestMethod::Patch => "PATCH", WorkspaceRequestMethod::Delete => "DELETE", }; + let source = self.source.lock().unwrap().clone(); let signed = runtime_source_request( &self.identity, - Some(&self.source.worker_id), + Some(&source.worker_id), method, &request.path, request.body.unwrap_or_default().into_bytes(), @@ -225,7 +226,7 @@ fn ticket_client( Arc::new(TicketRouterClient { api: api.clone(), identity: Arc::new(identity), - source: source.clone(), + source: Mutex::new(source.clone()), runtime: tokio::runtime::Handle::current(), lose_next_response: AtomicBool::new(false), requests: Mutex::new(Vec::new()), @@ -307,18 +308,16 @@ async fn ticket_public_roundtrip_observed_answer_replays_without_changing_waitin assert!(error.to_string().contains("409"), "{error}"); // A valid Runtime signature is not a Worker membership grant. The // Backend must reject this unregistered source through the same tools. - let denied = Arc::new(TicketRouterClient { - api: api.clone(), - identity: client.identity.clone(), - source: RuntimeWorkerRef::new( - source.runtime_id.clone(), - "00000000-0000-7000-8000-000000000099", - ), - runtime: tokio::runtime::Handle::current(), - lose_next_response: AtomicBool::new(false), - requests: Mutex::new(Vec::new()), - }); - let denied_surface = TicketSurface::new(&dir.path().join("denied"), denied, wip).await; + // Activate the real Feature while the source is registered, then lose + // membership before dispatch. Otherwise activation itself correctly rejects + // the fixture before it can exercise the operation's authentication check. + let denied = ticket_client(&api, (*client.identity).clone(), &source); + let denied_surface = + TicketSurface::new(&dir.path().join("denied"), denied.clone(), wip).await; + *denied.source.lock().unwrap() = RuntimeWorkerRef::new( + source.runtime_id.clone(), + "00000000-0000-7000-8000-000000000099", + ); let error = denied_surface .call("TicketAssignmentAnswer", input.clone(), context("denied")) .await diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index f6f407943..368a102f0 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -856,6 +856,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w workdir_attachments: Vec::new(), feature_connections: Default::default(), control_operation_id: None, + execution_grant: None, }), ) .await diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b9dd24f8a..b47bab7b5 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -21014,6 +21014,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx resolved_worker_observation_enabled: false, resolved_worker_observation_grants: Vec::new(), resolved_control_operation: None, + resolved_execution_grant: None, resolved_workspace_api: None, resolved_memory_settings: None, resolved_subjektiv_attached: false, diff --git a/openapi/server-api.json b/openapi/server-api.json index 8a8391a30..a1b294cb7 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1589,6 +1589,18 @@ "display_name": { "type": "string" }, + "execution_grant": { + "anyOf": [ + { + "$ref": "#/components/schemas/WorkerDelegationRequest" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Requested least-authority child permissions. Host derives the origin; this\ninput cannot assert Subject identity or create a body/singleton attachment." + }, "feature_connections": { "$ref": "#/components/schemas/WorkspaceWorkerFeatureConnectionsRequest", "description": "Explicit product Feature connections. Backend validation turns these into\ntrusted host attachments; Runtime and Profile inputs cannot self-attest them." @@ -6830,6 +6842,86 @@ ], "type": "object" }, + "PermissionResource": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "feature", + "type": "string" + } + }, + "required": [ + "kind" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workspace", + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "kind", + "workspace_id" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "workdir", + "type": "string" + }, + "path": { + "type": "string" + }, + "recursive": { + "type": "boolean" + }, + "working_directory_id": { + "type": "string" + } + }, + "required": [ + "kind", + "working_directory_id", + "path", + "recursive" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + } + ] + }, "ProfileSettingsResponse": { "additionalProperties": false, "description": "Read-only Profile catalog projected from one active Workspace config tree.", @@ -9527,6 +9619,22 @@ ], "type": "object" }, + "ScopedPermission": { + "additionalProperties": false, + "properties": { + "operation": { + "type": "string" + }, + "resource": { + "$ref": "#/components/schemas/PermissionResource" + } + }, + "required": [ + "operation", + "resource" + ], + "type": "object" + }, "Segment": { "oneOf": [ { @@ -10175,6 +10283,131 @@ ], "type": "object" }, + "SubjectPermissionInterruption": { + "additionalProperties": false, + "properties": { + "backend_command_sessions_closed": { + "type": "boolean" + }, + "cancellation_accepted": { + "type": "boolean" + }, + "diagnostics": { + "items": { + "type": "string" + }, + "type": "array" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "runtime_id", + "worker_id", + "cancellation_accepted", + "backend_command_sessions_closed", + "diagnostics" + ], + "type": "object" + }, + "SubjectPermissions": { + "additionalProperties": false, + "properties": { + "change_id": { + "type": "string" + }, + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "change_id", + "execution", + "delegation" + ], + "type": "object" + }, + "SubjectPermissionsMutationResponse": { + "additionalProperties": false, + "properties": { + "interruptions": { + "description": "New operations are already fenced. These are cancellation requests and\nBackend command cleanup evidence, not proof that a Runtime turn stopped.", + "items": { + "$ref": "#/components/schemas/SubjectPermissionInterruption" + }, + "type": "array" + }, + "permissions": { + "$ref": "#/components/schemas/SubjectPermissions" + } + }, + "required": [ + "permissions", + "interruptions" + ], + "type": "object" + }, + "SubjectPermissionsResponse": { + "additionalProperties": false, + "properties": { + "permissions": { + "anyOf": [ + { + "$ref": "#/components/schemas/SubjectPermissions" + }, + { + "type": "null" + } + ] + } + }, + "type": "object" + }, + "SubjectPermissionsUpdate": { + "additionalProperties": false, + "properties": { + "delegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "execution": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "expected_change_id": { + "type": [ + "string", + "null" + ] + }, + "operation_id": { + "type": "string" + } + }, + "required": [ + "operation_id", + "execution", + "delegation" + ], + "type": "object" + }, "SubjektivMemoryChangeItem": { "additionalProperties": false, "properties": { @@ -14899,6 +15132,28 @@ } ] }, + "WorkerDelegationRequest": { + "additionalProperties": false, + "properties": { + "permissions": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + }, + "redelegation": { + "items": { + "$ref": "#/components/schemas/ScopedPermission" + }, + "type": "array" + } + }, + "required": [ + "permissions", + "redelegation" + ], + "type": "object" + }, "WorkerFileDeleteResponse": { "additionalProperties": false, "properties": { @@ -17844,7 +18099,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb" + "x-yoi-source-digest": "sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01" }, "openapi": "3.1.0", "paths": { @@ -35000,6 +35255,212 @@ ] } }, + "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/permissions": { + "get": { + "operationId": "subjektiv_subject_permissions_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "subjektiv_subject_permissions_set", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "subject_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubjectPermissionsMutationResponse" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/subjektiv/subjects/{subject_id}/retire": { "post": { "operationId": "subjektiv_subject_retire", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 8f09d3f79..4d8a78ea5 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index c3274a7df..9437b3e7c 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index a07539f4d..c99bf5f4a 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index aeef96d93..8ed7a0fe8 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index bf13dfcb5..b9eb0cc56 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 446685098..00c09e774 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:becabc09349e8011b1a5d96b0126c3bb50697ffa4e3b400b92ec1f79a966aafb) +// Input contract: 0.1.0 (sha256:e151963ec3fa9836f43bea7f0f4f17b4a18798b43c111c983c6a2e2ccfd61a01) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -11,7 +11,7 @@ export type BrowserWorkerWorkingDirectorySelection = { alias: string; relative_c export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array; disposition: string; online: boolean; worker?: WorkerLaunchWorkerSummary | null; workspace_id: string; }; -export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; +export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; execution_grant?: WorkerDelegationRequest | null; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array; }; export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; }; @@ -35,6 +35,8 @@ export type PasteArtifactMediaType = "text_plain_utf8"; export type PasteArtifactRef = { artifact_id: string; availability: PasteArtifactAvailability; byte_len: number; char_count: number; created_at_ms: number; line_count: number; media_type: PasteArtifactMediaType; sha256: string; source_entry_id: string; }; +export type PermissionResource = { kind: "feature"; } | { kind: "workspace"; workspace_id: string; } | { kind: "workdir"; path: string; recursive: boolean; working_directory_id: string; } | { kind: "worker"; runtime_id: string; worker_id: string; }; + export type RepositoryApiError = { diagnostics?: Array; error: string; message: string; }; export type RuntimeWorkerWorkdirAttachmentSummary = { alias: string; working_directory: RuntimeWorkingDirectorySummary; }; @@ -43,6 +45,8 @@ export type RuntimeWorkingDirectoryCleanupTarget = { kind: string; repository_id export type RuntimeWorkingDirectorySummary = { cleanliness?: string | null; cleanup_target?: RuntimeWorkingDirectoryCleanupTarget | null; creation_ref?: string | null; creation_selector?: string | null; creation_tree?: string | null; current_ref?: string | null; current_selector?: string | null; current_tree?: string | null; display_name?: string | null; materializer_kind: WorkingDirectoryMaterializerKind; observed_at_epoch_seconds?: number | null; occupied_by?: WorkingDirectoryOccupancy | null; repository_id: string; status: WorkingDirectoryStatusKind; working_directory_id: string; }; +export type ScopedPermission = { operation: string; resource: PermissionResource; }; + export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; export type SubjektivWorkerConnectionRequest = { subject_id: string; }; @@ -55,6 +59,8 @@ export type UploadedFileRef = { artifact_id: string; availability: UploadedFileA export type WorkerBusyState = { kind: "run"; state: WorkerRunState; } | { kind: "maintenance"; state: WorkerMaintenanceState; }; +export type WorkerDelegationRequest = { permissions: Array; redelegation: Array; }; + export type WorkerImplementationSummary = { display_hint: string; kind: string; }; export type WorkerLaunchFeatureConnectionOptions = { subjektiv: boolean; };