feat: enforce explicit Subject permissions and delegated Worker grants

This commit is contained in:
2026-10-20 06:22:58 +09:00
parent a774925dec
commit b135fa5e99
52 changed files with 7422 additions and 100 deletions
File diff suppressed because it is too large Load Diff
+14
View File
@@ -145,6 +145,13 @@ where
pub async fn prepare(
self,
) -> Result<PreparedWorker<Box<dyn LlmClient>, 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));
}
+26 -3
View File
@@ -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<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError>
{
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();
+270
View File
@@ -1751,6 +1751,59 @@ pub enum FeaturePlanError {
/// Builder/installer for enabled feature modules.
pub struct FeatureRegistryBuilder {
modules: Vec<Arc<dyn FeatureModule>>,
activation_guard: Option<Arc<dyn Fn() -> 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<M>(&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<String, FeatureId>,
) -> 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<String>,
requested: std::sync::Mutex<Vec<String>>,
}
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<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
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")
@@ -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<Option<server_api::authorization::PermissionResource>, 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,
@@ -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<String>,
pub ticket_id: Option<String>,
/// Required for ordinary and Ticket spawn; exact retries reuse this ID and full input.
pub operation_id: Option<String>,
/// Explicit requested authority. Profile selection alone grants nothing.
pub execution_grant: Option<server_api::authorization::WorkerDelegationRequest>,
pub display_name: String,
pub initial_submit: Vec<Segment>,
}
@@ -514,25 +516,27 @@ struct WorkspaceWorkerLifecycleService {
fn workspace_worker_create_request(
request: WorkerLifecycleSpawnRequest,
) -> Result<CreateWorkspaceWorkerRequest, WorkspaceClientError> {
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<String>,
/// 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<server_api::authorization::WorkerDelegationRequest>,
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.")],
})
@@ -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()))?;
+365 -3
View File
@@ -509,6 +509,15 @@ const TICKET_SERVICE_VERSION: &str = "1";
pub trait TicketService: Send + Sync {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError>;
/// 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<TicketRef> {
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<TicketHandoff, TicketError> {
@@ -545,6 +555,17 @@ impl TicketService for WorkspaceTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
ticket_handoff_from_record(self.backend.show_unprojected(ticket_ref)?)
}
fn create(&self, input: NewTicket) -> TicketResult<TicketRef> {
// 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<dyn TicketService> = 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<dyn WorkspaceClient> = Arc::new(
crate::worker::TestWorkspaceHttpClient::new("workspace", "http://backend"),
);
let client: Arc<dyn WorkspaceClient> = 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<Vec<WorkspaceRequest>>,
}
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<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
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::<dyn TicketService>(&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::<dyn TicketService>(&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<TicketHandoff, TicketError> {
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();
+1
View File
@@ -1,5 +1,6 @@
#![doc = include_str!("../README.md")]
pub mod authorized_workdir;
mod backend_job;
pub use backend_job::BackendJobExecutionBinding;
pub mod bootstrap;
+25 -2
View File
@@ -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<WorkspaceResponse, WorkspaceClientError> {
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<agen::tool::ToolMeta>) {
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()
+6
View File
@@ -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
}
+8 -3
View File
@@ -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")
);
+376 -1
View File
@@ -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<Option<server_api::authorization::PermissionResource>, 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::<OperationAuthorizationResponse>(&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<Option<server_api::authorization::PermissionResource>, 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<String>,
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<WorkspaceResponse>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
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<WorkspaceResponse, WorkspaceClientError> {
panic!("authorization must select the bounded transport")
}
fn execute_with_timeout(
&self,
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
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::<serde_json::Value>(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<WorkspaceId>) -> 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<C: LlmClient + 'static, St: Store> Worker<C, St> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
return Ok(WorkspaceResponse {
status: 200,
body: "{}".into(),
});
}
self.requests
.lock()
.expect("Flow source request lock")
+20 -2
View File
@@ -225,8 +225,14 @@ impl WorkspaceClient for AvailableWorkspaceClient {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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<WorkspaceResponse, WorkspaceClientError> {
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") {
(