Authorize Feature effects and protected input at operation boundaries

Remove non-MCP activation gates without expanding legacy grants. Bind resource resolution, background Job attenuation, protected prompt provenance and repeated model supply to current Host authority. Align generated contracts and regression fixtures.
This commit is contained in:
2026-10-28 11:44:34 +09:00
parent 618880d38d
commit 960a1b4364
75 changed files with 13191 additions and 1143 deletions
+83 -15
View File
@@ -1523,9 +1523,13 @@ where
.register_tool(tools::routed_view_image_tool(workdir_sessions));
}
if feature_config.web.enabled {
worker
.engine_mut()
.register_tools(tools::web_builtin_tools(web_config));
let web = tools::WebTools::new(web_config).with_authorizer(Arc::new(
crate::feature::builtin::web::WorkspaceWebAuthorizer(worker.workspace_client_handle()),
));
worker.engine_mut().register_tools(vec![
tools::web_search_tool(web.clone()),
tools::web_fetch_tool(web),
]);
}
let worker_enabled = feature_config.worker.enabled;
@@ -1553,6 +1557,18 @@ where
"Backend Job result capability requires Backend Workspace API authority",
));
}
if binding.subjektiv_consolidation || binding.subjektiv_journal {
feature_registry.add_module(
crate::feature::builtin::backend_job_input::SubjectJobInputFeature::new(
workspace_client.clone(),
if binding.subjektiv_consolidation {
"memory.consolidation.supply"
} else {
"journal.job.input.supply"
},
),
);
}
let mut result_feature =
crate::feature::builtin::backend_job_result::BackendJobResultFeature::new(
workspace_client.clone(),
@@ -1655,7 +1671,6 @@ where
if ordinary_subjektiv_features_enabled
&& lifecycle_enabled
&& let Some(host) = subjektiv_host.as_ref()
&& (host.host.journal_startup_granted() || host.host.journal_compaction_granted())
{
worker.install_session_context_source(Arc::new(
crate::feature::builtin::journal::JournalContextSource::new(
@@ -1765,8 +1780,18 @@ where
);
}
let wip_mode = worker.manifest().worker.mode == manifest::WorkerMode::Wip;
let drive_client = worker.workspace_client_handle();
if feature_config.drive.enabled
&& (!drive_client.is_available()
|| drive_client.workspace_id().is_none_or(|id| id.is_empty()))
{
return Err(std::io::Error::new(
std::io::ErrorKind::NotConnected,
"Drive is configured but its Backend Workspace connection is unavailable; no Drive access started",
));
}
let drive_feature = crate::feature::builtin::drive::DriveFeature::configured(
worker.workspace_client_handle(),
drive_client,
feature_config.drive.enabled,
worker.workdir_sessions(),
)
@@ -4088,6 +4113,8 @@ mod tests {
struct DriveWorkspaceHost {
available: bool,
workspace: Option<&'static str>,
granted: bool,
operations: Arc<std::sync::Mutex<Vec<String>>>,
}
impl crate::worker::WorkspaceClient for DriveWorkspaceHost {
@@ -4107,11 +4134,25 @@ mod tests {
{
if request.path == "/api/w/workspace-1/authorization/check" {
assert_eq!(request.method, crate::worker::WorkspaceRequestMethod::Post);
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(matches!(
input.operation.as_str(),
"drive.capabilities" | "drive.root"
));
assert_eq!(
input.resource,
server_api::authorization::PermissionResource::Workspace {
workspace_id: "workspace-1".into()
}
);
self.operations.lock().unwrap().push(input.operation);
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope":null}).to_string(),
status: if self.granted { 200 } else { 403 },
body: serde_json::json!({"scope":input.resource}).to_string(),
});
}
self.operations.lock().unwrap().push(request.path.clone());
// 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" {
@@ -4132,14 +4173,16 @@ mod tests {
}
#[tokio::test]
async fn drive_controller_wiring_requires_activation_and_workspace_host_in_both_modes() {
async fn drive_controller_registers_without_authorization_and_checks_each_access_in_both_modes()
{
use crate::feature::FeatureModule as _;
for mode in [manifest::WorkerMode::Tools, manifest::WorkerMode::Wip] {
for (enabled, available, workspace) in [
(false, true, Some("workspace-1")),
(true, false, Some("workspace-1")),
(true, true, None),
(true, true, Some("workspace-1")),
for (enabled, available, workspace, granted) in [
(false, true, Some("workspace-1"), false),
(true, false, Some("workspace-1"), false),
(true, true, None, false),
(true, true, Some("workspace-1"), false),
(true, true, Some("workspace-1"), true),
] {
let root = TempDir::new().unwrap();
let mut manifest = manifest::WorkerManifest::from_toml(
@@ -4156,10 +4199,13 @@ mod tests {
session_store::FsStore::new(root.path().join("sessions")).unwrap(),
session_store::FsWorkerStore::new(root.path().join("workers")).unwrap(),
);
let operations = Arc::new(std::sync::Mutex::new(Vec::new()));
let host: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(DriveWorkspaceHost {
available,
workspace,
granted,
operations: operations.clone(),
});
let context = crate::worker::WorkerWorkspaceContext::with_client(
workspace.map(|id| crate::worker::WorkspaceId::new(id).unwrap()),
@@ -4201,12 +4247,17 @@ mod tests {
if enabled && (!available || workspace.is_none()) {
assert_eq!(
registered.unwrap_err().kind(),
std::io::ErrorKind::PermissionDenied
std::io::ErrorKind::NotConnected
);
assert!(operations.lock().unwrap().is_empty());
assert!(requests.lock().unwrap().is_empty());
continue;
}
registered.unwrap();
assert!(
operations.lock().unwrap().is_empty(),
"registration must not access Drive or authorize activation"
);
let configured = enabled && available && workspace.is_some();
let tools = worker.engine().tool_server_handle();
tools.flush_pending();
@@ -4218,6 +4269,13 @@ mod tests {
tool.name
);
}
if configured && mode == manifest::WorkerMode::Tools {
let (_, root_tool) = tools.get_tool("DriveRoot").unwrap();
let output = root_tool
.execute("{}", ToolExecutionContext::direct())
.await;
assert_eq!(output.is_ok(), granted, "{output:?}");
}
if mode == manifest::WorkerMode::Wip {
let (_, tree) = tools.get_tool("Tree").unwrap();
let output = tree
@@ -4226,7 +4284,7 @@ mod tests {
ToolExecutionContext::new("drive-wiring-tree", "batch", 0),
)
.await;
assert_eq!(output.is_ok(), configured, "{output:?}");
assert_eq!(output.is_ok(), configured && granted, "{output:?}");
if let Ok(output) = output {
let tree: serde_json::Value =
serde_json::from_str(&output.content.unwrap()).unwrap();
@@ -4236,6 +4294,16 @@ mod tests {
assert_eq!(tree["children"], serde_json::Value::Null);
}
}
if configured && !granted {
assert!(
operations
.lock()
.unwrap()
.iter()
.all(|operation| !operation.starts_with('/')),
"denied access must not reach Drive provider"
);
}
worker.run_text("Inspect enabled features").await.unwrap();
let requests = requests.lock().unwrap();
let prompt = requests[0].system_prompt.as_deref().unwrap_or("");
+14 -69
View File
@@ -1534,7 +1534,7 @@ impl ProtocolProviderRegistrar<'_> {
}
}
/// Restore preserves the saved Feature composition; only new activation asks
/// Restore preserves saved composition; only new MCP activation asks
/// for admission. Neither mode grants authority for protected operations.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(crate) enum FeatureInstallation {
@@ -1545,7 +1545,6 @@ pub(crate) enum FeatureInstallation {
/// Install-time context provided to a feature module.
pub struct FeatureInstallContext<'a> {
installation: FeatureInstallation,
feature_id: &'a FeatureId,
declarations: &'a FeatureContributionDeclarations,
pending_tools: &'a mut Vec<ToolDefinition>,
@@ -1558,10 +1557,6 @@ pub struct FeatureInstallContext<'a> {
}
impl FeatureInstallContext<'_> {
pub(crate) fn is_restoring(&self) -> bool {
self.installation == FeatureInstallation::Restore
}
pub fn feature_id(&self) -> &FeatureId {
self.feature_id
}
@@ -1769,52 +1764,13 @@ pub struct FeatureRegistryBuilder {
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(())
}
/// MCP alone retains its temporary activation check before discovery can spawn
/// a process. Local Feature composition is not authority; each trusted Feature
/// checks its concrete protected operation at the access/effect boundary.
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")?;
}
@@ -2254,7 +2210,6 @@ impl FeatureRegistryBuilder {
let install_result = {
let mut context = FeatureInstallContext {
installation: self.installation,
feature_id: &descriptor.id,
declarations: &declarations,
pending_tools,
@@ -2468,7 +2423,7 @@ mod tests {
}
#[test]
fn requested_activation_uses_exact_public_config_names_before_install() {
fn local_feature_configuration_makes_no_activation_requests() {
let mut config = manifest::FeatureConfig::default();
config.workdir_catalog.enabled = false;
// Enabling each config key independently also detects default-on flags.
@@ -2504,16 +2459,15 @@ mod tests {
allowed: vec![name.into()],
requested: Default::default(),
};
authorize_requested_features(&client, &requested).unwrap();
assert_eq!(*client.requested.lock().unwrap(), [name], "{name}");
let mut manifest = manifest::WorkerManifest::from_toml("[worker]\nname='local-features'\n[model]\nscheme='anthropic'\nmodel_id='scripted'\n[engine]\n[scope]\nallow=[]").unwrap();
manifest.feature = requested;
authorize_requested_manifest(&client, &manifest).unwrap();
assert!(client.requested.lock().unwrap().is_empty(), "{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;
fn feature_registration_never_requires_ticket_activation_permission() {
for allowed in [vec![], vec!["ticket.create"], vec!["ticket"]] {
let client = Arc::new(ActivationClient {
allowed: allowed.iter().map(|name| name.to_string()).collect(),
@@ -2524,22 +2478,13 @@ mod tests {
.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 registry = FeatureRegistryBuilder::new().with_module(module);
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!(client.requested.lock().unwrap().is_empty());
assert!(!report.has_errors());
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(tools.is_empty());
if !permitted {
assert!(report.error_message().contains("ticket"));
}
}
}
+2
View File
@@ -5,6 +5,7 @@
//! an external plugin-loading surface.
mod backend_diagnostic;
pub(crate) mod backend_job_input;
pub mod backend_job_result;
pub(crate) mod bash;
@@ -37,6 +38,7 @@ pub(crate) mod subjektiv_memory;
pub(crate) mod subjektiv_session;
pub mod task;
pub mod ticket;
pub(crate) mod web;
pub mod worker_observation;
pub mod workspace_config;
pub mod workspace_worker_discovery;
@@ -0,0 +1,160 @@
//! Rechecks frozen Subject Job input before each model request, including restore.
use std::sync::Arc;
use async_trait::async_trait;
use crate::feature::{
FeatureDescriptor, FeatureHookPoint, FeatureInstallContext, FeatureInstallError, FeatureModule,
HookDeclaration,
};
use crate::hook::{
Hook, HookError, HookErrorCategory, HookExecutionPolicy, HookPreRequestAction, PreLlmRequest,
PreRequestContext,
};
use crate::worker::WorkspaceClient;
#[derive(Clone)]
pub(crate) struct SubjectJobInputFeature {
client: Arc<dyn WorkspaceClient>,
operation: &'static str,
}
impl SubjectJobInputFeature {
pub(crate) fn new(client: Arc<dyn WorkspaceClient>, operation: &'static str) -> Self {
Self { client, operation }
}
}
impl FeatureModule for SubjectJobInputFeature {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("subject-job-input", "Subject Job input").with_hook(
HookDeclaration::new("supply", FeatureHookPoint::PreLlmRequest),
)
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.hooks().add_pre_llm_request(
"supply",
HookExecutionPolicy::fail_closed(),
self.clone(),
)
}
}
#[async_trait]
impl Hook<PreLlmRequest> for SubjectJobInputFeature {
async fn call(&self, _: &PreRequestContext) -> Result<HookPreRequestAction, HookError> {
self.client
.authorize_subject_operation(self.operation)
.map_err(|error| {
HookError::new(
HookErrorCategory::Dependency,
format!(
"protected Job input supply rejected for {}: {error}",
self.operation
),
)
})?;
Ok(HookPreRequestAction::Continue)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::hook::{HookRegistryBuilder, PreRequestInfo};
use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse};
use agen::llm_client::{ClientError, LlmClient, Request, ResponseStream};
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
#[derive(Clone)]
struct NoModel;
#[async_trait]
impl LlmClient for NoModel {
fn clone_boxed(&self) -> Box<dyn LlmClient> {
Box::new(self.clone())
}
async fn stream(&self, _: Request) -> Result<ResponseStream, ClientError> {
panic!("registry fixture must not dispatch a provider request")
}
}
#[derive(Debug)]
struct Host {
allowed: AtomicBool,
calls: AtomicUsize,
operation: &'static str,
}
impl WorkspaceClient for Host {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"job-input-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
assert_eq!(request.path, "/api/w/workspace/authorization/bound-check");
let input: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.operation, self.operation);
assert_eq!(
input.target,
server_api::authorization::BoundAuthorizationTarget::Subject {}
);
self.calls.fetch_add(1, Ordering::SeqCst);
Ok(WorkspaceResponse {
status: if self.allowed.load(Ordering::SeqCst) { 200 } else { 403 },
body: r#"{"scope":{"kind":"subject","workspace_id":"workspace","subject_id":"bound-subject"}}"#.into(),
})
}
}
#[tokio::test]
async fn direct_install_and_restore_recheck_each_frozen_job_input_supply() {
for operation in ["memory.consolidation.supply", "journal.job.input.supply"] {
let host = Arc::new(Host {
allowed: AtomicBool::new(true),
calls: AtomicUsize::new(0),
operation,
});
let feature = SubjectJobInputFeature::new(host.clone(), operation);
for restored in [false, true] {
let mut engine = agen::Engine::new(NoModel);
let mut hooks = HookRegistryBuilder::new();
let mut registry = FeatureRegistryBuilder::new().with_module(feature.clone());
if restored {
registry =
registry.with_installation(crate::feature::FeatureInstallation::Restore);
}
let report = registry.install_into_engine(&mut engine, &mut hooks);
assert!(report.reports[0].installed);
let hooks = hooks.build();
let context = PreRequestContext::new(
PreRequestInfo {
item_count: 1,
estimated_tokens: None,
turn_index: 0,
tool_calls_this_turn: 0,
},
None,
);
host.allowed.store(true, Ordering::SeqCst);
assert!(matches!(
hooks.pre_llm_request[0].call(&context).await.unwrap(),
HookPreRequestAction::Continue
));
host.allowed.store(false, Ordering::SeqCst);
let rejected = hooks.pre_llm_request[0].call(&context).await.unwrap_err();
assert!(rejected.source.diagnostic.contains("403"));
assert_eq!(rejected.policy, crate::hook::HookFailurePolicy::FailClosed);
}
assert_eq!(host.calls.load(Ordering::SeqCst), 4);
}
}
}
@@ -17,6 +17,7 @@ use crate::worker::{
WorkspaceBinaryRequest, WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod,
};
use serde::{Serialize, de::DeserializeOwned};
use server_api::authorization::PermissionResource;
use server_api::*;
pub(super) const DEADLINE: Duration = Duration::from_secs(30);
@@ -30,6 +31,14 @@ pub struct DriveBackend {
#[derive(Debug, Clone, thiserror::Error)]
pub enum DriveError {
#[error(
"Drive operation authorization failed before dispatch: {operation}; resource={resource:?}; denied={denied}"
)]
Authorization {
operation: String,
resource: PermissionResource,
denied: bool,
},
#[error("Drive access denied")]
Denied,
#[error("Drive resource not found")]
@@ -63,6 +72,31 @@ impl DriveBackend {
.filter(|id| !id.is_empty() && self.client.is_available())
.ok_or(DriveError::Denied)
}
fn authorize(&self, operation: &str, resource: PermissionResource) -> Result<(), DriveError> {
self.client
.authorize_operation(operation, &resource)
.map_err(|error| {
let denied = matches!(&error, crate::worker::WorkspaceClientError::Request(message)
if message.contains("HTTP status 403") || message.contains("HTTP status 401"));
DriveError::Authorization {
operation: operation.into(),
resource,
denied,
}
})
}
fn workspace_resource(&self) -> Result<PermissionResource, DriveError> {
Ok(PermissionResource::Workspace {
workspace_id: self.workspace()?.into(),
})
}
fn entry_resource(&self, entry: &DriveEntryRef) -> Result<PermissionResource, DriveError> {
self.validate_ref(entry)?;
Ok(PermissionResource::DriveEntry {
workspace_id: entry.workspace_id.clone(),
node_id: entry.node_id.clone(),
})
}
fn base(&self) -> Result<String, DriveError> {
Ok(format!("/api/w/{}/drive", encode(self.workspace()?)))
}
@@ -176,6 +210,7 @@ impl DriveBackend {
.await
}
pub async fn capabilities(&self) -> Result<DriveCapabilities, DriveError> {
self.authorize("drive.capabilities", self.workspace_resource()?)?;
self.get(
"/capabilities",
&serde_json::json!({}),
@@ -184,6 +219,7 @@ impl DriveBackend {
.await
}
pub async fn root(&self) -> Result<DriveEntry, DriveError> {
self.authorize("drive.root", self.workspace_resource()?)?;
let diagnostic = Diagnostic::new(Adapter::Drive, "root", false, None);
let entry: DriveEntry = self
.json(
@@ -201,6 +237,7 @@ impl DriveBackend {
Ok(entry)
}
pub async fn metadata(&self, entry: &DriveEntryRef) -> Result<DriveEntry, DriveError> {
self.authorize("drive.metadata", self.entry_resource(entry)?)?;
self.validate_ref(entry)?;
let diagnostic = Diagnostic::new(Adapter::Drive, "metadata", false, None);
let result: DriveEntry = self
@@ -226,6 +263,7 @@ impl DriveBackend {
limit: Option<u32>,
after: Option<String>,
) -> Result<DriveListResponse, DriveError> {
self.authorize("drive.list", self.entry_resource(parent)?)?;
self.validate_ref(parent)?;
let diagnostic = Diagnostic::new(Adapter::Drive, "list", false, None);
let result: DriveListResponse = self
@@ -258,6 +296,7 @@ impl DriveBackend {
Ok(result)
}
pub async fn search(&self, query: DriveSearchQuery) -> Result<DriveListResponse, DriveError> {
self.authorize("drive.search", self.workspace_resource()?)?;
let diagnostic = Diagnostic::new(Adapter::Drive, "search", false, None);
let result: DriveListResponse = self.get("/search", &query, &diagnostic).await?;
self.validate_page(
@@ -290,6 +329,7 @@ impl DriveBackend {
entry: &DriveEntryRef,
max_bytes: u32,
) -> Result<DriveReadTextResponse, DriveError> {
self.authorize("drive.read_text", self.entry_resource(entry)?)?;
self.validate_ref(entry)?;
let diagnostic = Diagnostic::new(Adapter::Drive, "read_text", false, None);
let result: DriveReadTextResponse = self
@@ -341,6 +381,7 @@ impl DriveBackend {
body: None,
max_response_bytes: DRIVE_CHUNK_MAX_BYTES as usize,
};
self.authorize("drive.read_chunk", self.entry_resource(&entry.entry)?)?;
let client = self.client.clone();
let deadline = self.deadline;
let response = tokio::time::timeout(
@@ -454,6 +495,24 @@ impl DriveBackend {
diagnostic.record(Stage::Request, Reason::RequestSize, None);
return Err(DriveError::Limit);
}
match &request.mutation {
DriveMutation::CreateFolder { parent, .. } => {
self.authorize("drive.create_folder", self.entry_resource(parent)?)?
}
DriveMutation::CreateText { parent, .. } => {
self.authorize("drive.create_text", self.entry_resource(parent)?)?
}
DriveMutation::UpdateText { id, .. } => {
self.authorize("drive.update_text", self.entry_resource(id)?)?
}
DriveMutation::Delete { id, .. } => {
self.authorize("drive.delete", self.entry_resource(id)?)?
}
DriveMutation::Relocate { id, parent, .. } => {
self.authorize("drive.relocate", self.entry_resource(id)?)?;
self.authorize("drive.relocate", self.entry_resource(parent)?)?;
}
}
let result = self
.json(
WorkspaceRequest::json(
@@ -479,6 +538,18 @@ impl DriveBackend {
diagnostic.record(Stage::Request, Reason::RequestSize, None);
return Err(DriveError::Limit);
}
let node_id = match query.operation {
DriveUploadOperation::Create => query.parent_id.as_ref(),
DriveUploadOperation::Update => query.id.as_ref(),
}
.ok_or_else(|| DriveError::Invalid("upload target required".into()))?;
self.authorize(
"drive.upload",
self.entry_resource(&DriveEntryRef {
workspace_id: query.entry_workspace_id.clone(),
node_id: node_id.clone(),
})?,
)?;
let path = format!("{}/upload{}", self.base()?, query_string(&query)?);
let request = WorkspaceBinaryRequest {
method: WorkspaceRequestMethod::Put,
@@ -540,6 +611,13 @@ impl DriveBackend {
if request_id.is_empty() || request_id.len() > 256 {
return Err(DriveError::Invalid("invalid request ID".into()));
}
self.authorize(
"drive.request_status",
PermissionResource::DriveRequest {
workspace_id: self.workspace()?.into(),
request_id: request_id.into(),
},
)?;
let response: DriveRequestStatusResponse = self
.json(
WorkspaceRequest::get(format!("{}/requests/{}", self.base()?, encode(request_id))),
@@ -170,6 +170,14 @@ impl WorkspaceClient for Client {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
return Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
let is_status = request.path.contains("/requests/");
let fault = if is_status { self.receipt } else { self.fault };
let mutation = request.method == WorkspaceRequestMethod::Post;
@@ -542,7 +550,7 @@ async fn production_deadline_does_not_cancel_effect_or_infer_uncommitted_from_sn
}
}
#[tokio::test]
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn http_reply_loss_keeps_original_receipt_and_retained_effect_without_reexecution() {
use axum::{
Router,
@@ -563,6 +571,7 @@ async fn http_reply_loss_keeps_original_receipt_and_retained_effect_without_reex
}
let stored = Arc::new(Mutex::new(Stored::default()));
let router = Router::new()
.route("/api/w/workspace/authorization/check", post(|body: axum::body::Bytes| async move { let input: server_api::authorization::OperationAuthorizationRequest = serde_json::from_slice(&body).unwrap(); axum::Json(serde_json::json!({"scope":input.resource})) }))
.route("/api/w/workspace/drive/metadata", get(|| async { SECRET }))
.route(
"/api/w/workspace/drive/mutate",
@@ -624,7 +633,7 @@ async fn http_reply_loss_keeps_original_receipt_and_retained_effect_without_reex
.with_subscriber(capture.dispatch())
.await
.unwrap_err();
assert!(matches!(error, DriveError::Unavailable));
assert!(matches!(error, DriveError::Unavailable), "{error:?}");
safe(&capture, Reason::Decode, &error.to_string());
let capture = Capture::default();
let response = backend
@@ -5,6 +5,9 @@ use std::collections::VecDeque;
#[derive(Debug)]
struct Client {
auth_denied: std::sync::atomic::AtomicBool,
denied_resource: Mutex<Option<server_api::authorization::PermissionResource>>,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
responses: Mutex<VecDeque<Result<WorkspaceResponse, WorkspaceClientError>>>,
requests: Mutex<Vec<WorkspaceRequest>>,
capabilities: Mutex<WorkspaceResponse>,
@@ -12,6 +15,9 @@ struct Client {
impl Client {
fn new(responses: Vec<WorkspaceResponse>) -> Arc<Self> {
Arc::new(Self {
auth_denied: std::sync::atomic::AtomicBool::new(false),
denied_resource: Mutex::new(None),
authorizations: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into_iter().map(Ok).collect()),
requests: Mutex::new(Vec::new()),
capabilities: Mutex::new(response(DriveCapabilities { write: false })),
@@ -32,6 +38,23 @@ impl WorkspaceClient for Client {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.authorizations.lock().unwrap().push(input.clone());
if self.auth_denied.load(std::sync::atomic::Ordering::SeqCst)
|| self.denied_resource.lock().unwrap().as_ref() == Some(&input.resource)
{
return Ok(WorkspaceResponse {
status: 403,
body: "{}".into(),
});
}
return Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
let capabilities = request.path == "/api/w/workspace/drive/capabilities";
self.requests.lock().unwrap().push(request);
if capabilities {
@@ -520,6 +543,7 @@ async fn drive_native_entry_is_directly_resolvable_but_never_indexable_and_binds
#[derive(Debug)]
struct ChunkClient {
revoke_after_chunk: std::sync::atomic::AtomicBool,
json: Arc<Client>,
chunks: Mutex<VecDeque<crate::worker::WorkspaceBinaryResponse>>,
requests: Mutex<Vec<crate::worker::WorkspaceBinaryRequest>>,
@@ -545,6 +569,14 @@ impl WorkspaceClient for ChunkClient {
request: crate::worker::WorkspaceBinaryRequest,
) -> Result<crate::worker::WorkspaceBinaryResponse, WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
if self
.revoke_after_chunk
.load(std::sync::atomic::Ordering::SeqCst)
{
self.json
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
}
Ok(self
.chunks
.lock()
@@ -558,6 +590,7 @@ fn chunk_client(
chunks: Vec<crate::worker::WorkspaceBinaryResponse>,
) -> Arc<ChunkClient> {
Arc::new(ChunkClient {
revoke_after_chunk: std::sync::atomic::AtomicBool::new(false),
json: Client::new(vec![response(metadata)]),
chunks: Mutex::new(chunks.into()),
requests: Mutex::new(Vec::new()),
@@ -1050,6 +1083,14 @@ impl WorkspaceClient for NativeMutations {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
return Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
let mut state = self.0.lock().unwrap();
let suffix = request
.path
@@ -1326,3 +1367,180 @@ async fn drive_same_feature_remount_rejects_retained_object_with_fresh_common_sc
assert!(result["content"].as_str().unwrap().contains("hello"));
assert!(client.0.lock().unwrap().2.is_empty());
}
// The real Tool/backend/client authorization connection is exercised; only Host transport is substituted.
#[tokio::test]
async fn denied_drive_operations_do_not_reach_provider_and_bind_actual_resources() {
use server_api::authorization::PermissionResource;
for operation in [
"capabilities",
"root",
"metadata",
"list",
"search",
"read_text",
"create_folder",
"create_text",
"update_text",
"relocate",
"delete",
"upload",
"request_status",
] {
let client = Client::new(vec![]);
client
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
let backend = DriveBackend::new(client.clone());
let entry = entry("2", "old");
let result = match operation {
"capabilities" => backend.capabilities().await.map(|_| ()),
"root" => backend.root().await.map(|_| ()),
"metadata" => backend.metadata(&entry.entry).await.map(|_| ()),
"list" => backend.list(&entry.entry, None, None).await.map(|_| ()),
"search" => backend
.search(serde_json::from_value(json!({"query":"note"})).unwrap())
.await
.map(|_| ()),
"read_text" => backend.read(&entry.entry, 10).await.map(|_| ()),
"request_status" => backend.status("request-1").await.map(|_| ()),
"upload" => backend
.upload(
DriveUploadQuery {
operation: DriveUploadOperation::Create,
request_id: "request-1".into(),
entry_workspace_id: "workspace".into(),
parent_id: Some("2".into()),
name: Some("new".into()),
id: None,
expected_mutation_id: None,
content_type: "text/plain".into(),
size: 0,
sha256: digest(b""),
},
vec![],
)
.await
.map(|_| ()),
_ => {
let mutation = match operation {
"create_folder" => DriveMutation::CreateFolder {
parent: entry.entry,
name: "new".into(),
},
"create_text" => DriveMutation::CreateText {
parent: entry.entry,
name: "new".into(),
text: "new".into(),
content_type: "text/plain".into(),
},
"update_text" => DriveMutation::UpdateText {
id: entry.entry,
expected_mutation_id: "old".into(),
text: "new".into(),
content_type: "text/plain".into(),
},
"relocate" => DriveMutation::Relocate {
id: entry.entry,
expected_mutation_id: "old".into(),
parent: DriveEntryRef {
workspace_id: "workspace".into(),
node_id: "3".into(),
},
name: "new".into(),
},
"delete" => DriveMutation::Delete {
id: entry.entry,
expected_mutation_id: "old".into(),
},
_ => unreachable!(),
};
backend
.mutate(DriveMutationRequest {
request_id: "request-1".into(),
mutation,
})
.await
.map(|_| ())
}
};
assert!(
matches!(result, Err(DriveError::Authorization { denied: true, .. })),
"{operation}: {result:?}"
);
assert!(client.requests.lock().unwrap().is_empty(), "{operation}");
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 1);
assert_eq!(auth[0].operation, format!("drive.{operation}"));
let expected = match operation {
"capabilities" | "root" | "search" => PermissionResource::Workspace {
workspace_id: "workspace".into(),
},
"request_status" => PermissionResource::DriveRequest {
workspace_id: "workspace".into(),
request_id: "request-1".into(),
},
_ => PermissionResource::DriveEntry {
workspace_id: "workspace".into(),
node_id: "2".into(),
},
};
assert_eq!(auth[0].resource, expected);
}
}
#[tokio::test]
async fn image_permission_revocation_blocks_next_chunk_and_returns_no_attachment() {
let mut metadata = entry("2", "old");
metadata.size = Some(DRIVE_CHUNK_MAX_BYTES + 1);
let client = chunk_client(
metadata.clone(),
vec![chunk(vec![0; DRIVE_CHUNK_MAX_BYTES as usize])],
);
client
.revoke_after_chunk
.store(true, std::sync::atomic::Ordering::SeqCst);
let feature = DriveFeature::new(client.clone(), Arc::new(WorkdirSessionRouter::new()));
let result = execute(&feature, "DriveViewImage", json!({"entry":metadata.entry})).await;
assert!(matches!(
result,
Err(DriveError::Authorization { denied: true, .. })
));
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn drive_relocate_checks_destination_before_any_mutation() {
use server_api::authorization::PermissionResource;
let client = Client::new(vec![]);
*client.denied_resource.lock().unwrap() = Some(PermissionResource::DriveEntry {
workspace_id: "workspace".into(),
node_id: "3".into(),
});
let backend = DriveBackend::new(client.clone());
let result = backend
.mutate(DriveMutationRequest {
request_id: "request-1".into(),
mutation: DriveMutation::Relocate {
id: entry("2", "old").entry,
expected_mutation_id: "old".into(),
parent: entry("3", "old").entry,
name: "moved".into(),
},
})
.await;
assert!(matches!(
result,
Err(DriveError::Authorization { denied: true, .. })
));
assert!(client.requests.lock().unwrap().is_empty());
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 2);
assert_eq!(
auth[1].resource,
PermissionResource::DriveEntry {
workspace_id: "workspace".into(),
node_id: "3".into()
}
);
}
@@ -518,7 +518,10 @@ fn failure(code: ProtocolErrorCode, message: &str) -> WipOperationError {
}
fn map_error(error: DriveError) -> WipOperationError {
let code = match &error {
DriveError::Denied => ProtocolErrorCode::PermissionDenied,
DriveError::Denied | DriveError::Authorization { denied: true, .. } => {
ProtocolErrorCode::PermissionDenied
}
DriveError::Authorization { denied: false, .. } => ProtocolErrorCode::Internal,
DriveError::NotFound => ProtocolErrorCode::NotFound,
DriveError::Conflict => ProtocolErrorCode::ValidatorMismatch,
DriveError::Invalid(_) => ProtocolErrorCode::InvalidArguments,
+340 -23
View File
@@ -25,7 +25,10 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolExecutionContext, ToolMeta
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use std::sync::Arc;
use std::sync::{
Arc,
atomic::{AtomicBool, Ordering},
};
use subjektiv::journal::transport::*;
use subjektiv::journal::{
JournalAction, JournalOperation, MAX_JOURNAL_PAGE, MAX_JOURNAL_READ_BYTES,
@@ -64,6 +67,7 @@ impl JournalReadStatus {
// These diagnostics belong to the Feature, not persisted/model-visible input.
enum JournalCompactionFailure {
Connection(std::io::ErrorKind),
SnapshotPermissionDenied,
SnapshotConflict,
SnapshotInvalidArgument,
SnapshotUnavailable,
@@ -74,6 +78,7 @@ enum JournalCompactionFailure {
impl JournalCompactionFailure {
fn snapshot(error: SubjektivHostError) -> Self {
match error {
SubjektivHostError::PermissionDenied(_) => Self::SnapshotPermissionDenied,
SubjektivHostError::Conflict { .. } => Self::SnapshotConflict,
SubjektivHostError::InvalidArgument(_) => Self::SnapshotInvalidArgument,
SubjektivHostError::Unavailable(_) => Self::SnapshotUnavailable,
@@ -83,9 +88,10 @@ impl JournalCompactionFailure {
fn stage(&self) -> &'static str {
match self {
Self::Connection(_) => "connection",
Self::SnapshotConflict | Self::SnapshotInvalidArgument | Self::SnapshotUnavailable => {
"snapshot"
}
Self::SnapshotPermissionDenied
| Self::SnapshotConflict
| Self::SnapshotInvalidArgument
| Self::SnapshotUnavailable => "snapshot",
Self::Serialization => "serialization",
Self::ByteBudget => "byte_budget",
}
@@ -97,6 +103,7 @@ impl JournalCompactionFailure {
Self::Connection(std::io::ErrorKind::InvalidInput) => "invalid_input",
Self::Connection(std::io::ErrorKind::NotConnected) => "not_connected",
Self::Connection(_) => "io_error",
Self::SnapshotPermissionDenied => "permission_denied",
Self::SnapshotConflict => "host_conflict",
Self::SnapshotInvalidArgument => "host_invalid_argument",
Self::SnapshotUnavailable => "host_unavailable",
@@ -144,22 +151,36 @@ fn journal_input(
pub(crate) struct JournalContextSource {
host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
startup_supplied: AtomicBool,
compaction_supplied: AtomicBool,
}
impl JournalContextSource {
pub(crate) fn new(
host: SubjektivHostConnection,
prompts: Arc<arc_swap::ArcSwap<crate::prompt::catalog::PromptCatalog>>,
) -> Self {
Self { host, prompts }
Self {
host,
prompts,
startup_supplied: AtomicBool::new(false),
compaction_supplied: AtomicBool::new(false),
}
}
}
#[async_trait]
impl crate::worker::SessionContextSource for JournalContextSource {
fn protected_input_kind(&self) -> Option<&'static str> {
Some("journal")
}
async fn load(&self) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, String> {
use sha2::{Digest, Sha256};
if !self.host.host.journal_startup_granted() {
return Ok(Vec::new());
}
self.host
.authorize_operation("journal.startup.read")
.map_err(|e| e.to_string())?;
let snapshot = self
.host
.journal_startup_context()
@@ -203,29 +224,75 @@ impl crate::worker::SessionContextSource for JournalContextSource {
},
)
.map_err(|e| e.to_string())?;
self.host
.authorize_operation("journal.startup.supply")
.map_err(|e| e.to_string())?;
let input = journal_input(body, payload, "journal_startup")?;
self.startup_supplied.store(true, Ordering::Release);
Ok(vec![session_store::LoggedSystemHistoryEntry::from_input(
session_store::LoggedSessionHistoryOrigin::Provided {
producer: "subjektiv.journal".into(),
},
None,
journal_input(body, payload, "journal_startup")?,
input,
)])
}
async fn authorize_supply(&self) -> Result<(), String> {
if self.startup_supplied.load(Ordering::Acquire) {
self.host
.authorize_operation("journal.startup.supply")
.map_err(|error| error.to_string())?;
}
if self.compaction_supplied.load(Ordering::Acquire) {
self.host
.authorize_operation("journal.compaction.supply")
.map_err(|error| error.to_string())?;
}
Ok(())
}
async fn authorize_retained_supply(&self, kind: &str) -> Result<(), String> {
let operations: &[&str] = match kind {
"journal-startup" => &["journal.startup.supply"],
"journal-compaction" => &["journal.compaction.supply"],
// Legacy Journal context cannot distinguish its original source.
// It may retain both kinds; it never obtains permission from flags
// reset by restore or from today's Feature configuration.
"journal" => &["journal.startup.supply", "journal.compaction.supply"],
_ => return Err("unknown retained Journal input kind".into()),
};
for operation in operations {
self.host
.authorize_operation(operation)
.map_err(|error| error.to_string())?;
}
Ok(())
}
async fn load_compaction_context(
&self,
session: session_store::SessionId,
) -> Result<Vec<session_store::LoggedSystemHistoryEntry>, String> {
// A changed Session must never reuse the old source's Host ownership.
let connection = if self.host.context.session_id == session.to_string() {
Ok(Some(self.host.clone()))
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"context source belongs to a different Session",
))
};
let input = freeze_compaction_journal(connection, &self.prompts.load_full())?;
if self.host.context.session_id != session.to_string() {
return Err("Journal context source belongs to a different Session".into());
}
if !self.host.host.journal_compaction_granted() {
return Ok(Vec::new());
}
self.host
.authorize_operation("journal.compaction.read")
.map_err(|error| error.to_string())?;
self.host
.authorize_operation("journal.compaction.supply")
.map_err(|error| error.to_string())?;
let input =
freeze_compaction_journal(Ok(Some(self.host.clone())), &self.prompts.load_full())?;
self.host
.authorize_operation("journal.compaction.supply")
.map_err(|error| error.to_string())?;
self.compaction_supplied.store(true, Ordering::Release);
Ok(vec![session_store::LoggedSystemHistoryEntry::from_input(
session_store::LoggedSessionHistoryOrigin::Provided {
producer: "subjektiv.journal".into(),
@@ -252,7 +319,12 @@ pub(crate) fn freeze_compaction_journal(
let read = match connection {
Ok(Some(connection)) if connection.host.journal_compaction_granted() => Some(
connection
.journal_compaction_context()
.authorize_operation("journal.compaction.read")
.and_then(|_| connection.journal_compaction_context())
.and_then(|snapshot| {
connection.authorize_operation("journal.compaction.supply")?;
Ok(snapshot)
})
.map_err(JournalCompactionFailure::snapshot),
),
Ok(_) => None,
@@ -344,14 +416,21 @@ impl JournalRecordingFeature {
}
fn request(&self, reason: JournalRecordingReason) -> Result<(), HookError> {
self.host
.host
.request_journal_recording(&self.host.context, reason)
.authorize_operation("journal.recording.request")
.and_then(|_| {
self.host
.host
.request_journal_recording(&self.host.context, reason)
})
.map(|_| ())
.map_err(|_| {
HookError::new(
HookErrorCategory::Dependency,
"Journal admission unconfirmed; Host must reconcile saved public history",
)
.map_err(|error| {
let diagnostic = match error {
SubjektivHostError::PermissionDenied(_) => {
"Journal journal.recording.request permission denied; admission not started"
}
_ => "Journal admission unconfirmed; Host must reconcile saved public history",
};
HookError::new(HookErrorCategory::Dependency, diagnostic)
})
}
}
@@ -852,6 +931,244 @@ mod tests {
assert!(logs.text().is_empty());
}
#[derive(Default)]
struct ContextHost {
allowed: Mutex<Vec<&'static str>>,
reads: std::sync::atomic::AtomicUsize,
requests: std::sync::atomic::AtomicUsize,
}
impl crate::subjektiv::SubjektivHost for ContextHost {
fn settings(&self) -> crate::subjektiv::SubjektivHostSettings {
crate::subjektiv::SubjektivHostSettings {
language: "English".into(),
}
}
fn authorize_operation(
&self,
_: &crate::subjektiv::SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
if self.allowed.lock().unwrap().contains(&operation) {
Ok(())
} else {
Err(SubjektivHostError::PermissionDenied(operation.into()))
}
}
fn journal_startup_granted(&self) -> bool {
true
}
fn journal_compaction_granted(&self) -> bool {
true
}
fn journal_recording_granted(&self) -> bool {
true
}
fn journal_startup_context(
&self,
_: &crate::subjektiv::SubjektivHostContext,
) -> Result<subjektiv::journal::startup::JournalStartupSnapshot, SubjektivHostError>
{
self.reads.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(subjektiv::journal::startup::JournalStartupSnapshot {
subject_id: "subject".into(),
journals: vec![],
omitted: true,
})
}
fn journal_compaction_context(
&self,
context: &crate::subjektiv::SubjektivHostContext,
) -> Result<subjektiv::journal::startup::JournalStartupSnapshot, SubjektivHostError>
{
self.journal_startup_context(context)
}
fn request_journal_recording(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: JournalRecordingReason,
) -> Result<JournalRecordingResponse, SubjektivHostError> {
self.requests
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
Ok(JournalRecordingResponse::NoNewSources)
}
fn memory(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: server_api::SubjektivMemoryBackendOperation,
) -> Result<server_api::SubjektivMemoryBackendResponse, SubjektivHostError> {
unreachable!()
}
fn session(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: server_api::SubjektivSessionBackendOperation,
) -> Result<server_api::SubjektivSessionBackendResponse, SubjektivHostError> {
unreachable!()
}
fn stage_candidate(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: memory::backend::MemoryStageCandidateOperation,
) -> Result<server_api::SubjektivStageCandidateResponse, SubjektivHostError> {
unreachable!()
}
fn record_session(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: bool,
) -> Result<server_api::SubjektivRecordSessionResponse, SubjektivHostError> {
unreachable!()
}
fn request_consolidation(
&self,
_: &crate::subjektiv::SubjektivHostContext,
_: memory::backend::MemoryConsolidateStagingOperation,
) -> Result<memory::backend::MemoryConsolidationOutput, SubjektivHostError> {
unreachable!()
}
}
fn context_connection(host: Arc<ContextHost>) -> SubjektivHostConnection {
SubjektivHostConnection::new(
host,
crate::subjektiv::SubjektivHostContext {
worker_id: "worker".into(),
session_id: "session".into(),
},
)
.unwrap()
}
#[tokio::test]
async fn startup_context_denial_never_reads_or_supplies_and_revocation_rechecks_supply() {
use crate::worker::SessionContextSource;
use std::sync::atomic::Ordering::SeqCst;
let host = Arc::new(ContextHost::default());
let source = JournalContextSource::new(
context_connection(host.clone()),
Arc::new(arc_swap::ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)),
);
source.authorize_supply().await.unwrap();
assert!(
source
.load()
.await
.unwrap_err()
.contains("journal.startup.read")
);
assert_eq!(host.reads.load(SeqCst), 0);
*host.allowed.lock().unwrap() = vec!["journal.startup.read", "journal.startup.supply"];
assert_eq!(source.load().await.unwrap().len(), 1);
source.authorize_supply().await.unwrap();
assert_eq!(host.reads.load(SeqCst), 1);
*host.allowed.lock().unwrap() = vec!["journal.startup.read"];
assert!(
source
.authorize_supply()
.await
.unwrap_err()
.contains("journal.startup.supply")
);
assert_eq!(host.reads.load(SeqCst), 1);
assert!(
source
.load()
.await
.unwrap_err()
.contains("journal.startup.supply")
);
assert_eq!(host.reads.load(SeqCst), 2);
}
#[tokio::test]
async fn cached_compaction_supply_rechecks_without_reading_or_requiring_startup_grant() {
use crate::worker::SessionContextSource;
use std::sync::atomic::Ordering::SeqCst;
let host = Arc::new(ContextHost::default());
let session = session_store::new_session_id();
let mut connection = context_connection(host.clone());
connection.context.session_id = session.to_string();
let source = JournalContextSource::new(
connection,
Arc::new(arc_swap::ArcSwap::from(
crate::prompt::catalog::PromptCatalog::builtins_only().unwrap(),
)),
);
assert!(source.load_compaction_context(session).await.is_err());
assert_eq!(host.reads.load(SeqCst), 0);
source.authorize_supply().await.unwrap();
*host.allowed.lock().unwrap() =
vec!["journal.compaction.read", "journal.compaction.supply"];
assert_eq!(
source.load_compaction_context(session).await.unwrap().len(),
1
);
source.authorize_supply().await.unwrap();
assert_eq!(host.reads.load(SeqCst), 1);
*host.allowed.lock().unwrap() = vec!["journal.compaction.read"];
assert!(
source
.authorize_supply()
.await
.unwrap_err()
.contains("journal.compaction.supply")
);
assert_eq!(host.reads.load(SeqCst), 1);
}
#[test]
fn compaction_and_recording_denial_has_zero_downstream_calls_and_revocation_is_live() {
use std::sync::atomic::Ordering::SeqCst;
let host = Arc::new(ContextHost::default());
let connection = context_connection(host.clone());
let prompts = crate::prompt::catalog::PromptCatalog::builtins_only().unwrap();
let denied = freeze_compaction_journal(Ok(Some(connection.clone())), &prompts).unwrap();
assert!(denied.body().contains("Journal read status: unavailable."));
assert_eq!(host.reads.load(SeqCst), 0);
let feature = JournalRecordingFeature::new(connection.clone()).unwrap();
assert!(
feature
.request(JournalRecordingReason::RunCommitted)
.is_err()
);
assert_eq!(host.requests.load(SeqCst), 0);
*host.allowed.lock().unwrap() = vec![
"journal.compaction.read",
"journal.compaction.supply",
"journal.recording.request",
];
let allowed = freeze_compaction_journal(Ok(Some(connection.clone())), &prompts).unwrap();
let payload: JournalCompactionInput = allowed
.metadata()
.feature_payload()
.unwrap()
.decode("journal:compaction", 1)
.unwrap();
assert!(payload.snapshot.is_some());
feature
.request(JournalRecordingReason::RunCommitted)
.unwrap();
assert_eq!(host.reads.load(SeqCst), 1);
assert_eq!(host.requests.load(SeqCst), 1);
*host.allowed.lock().unwrap() = vec!["journal.compaction.read"];
let denied = freeze_compaction_journal(Ok(Some(connection)), &prompts).unwrap();
let payload: JournalCompactionInput = denied
.metadata()
.feature_payload()
.unwrap()
.decode("journal:compaction", 1)
.unwrap();
assert!(payload.snapshot.is_none());
assert_eq!(host.reads.load(SeqCst), 2);
assert!(
feature
.request(JournalRecordingReason::RunCommitted)
.is_err()
);
assert_eq!(host.requests.load(SeqCst), 1);
}
#[derive(Default)]
struct Host {
requests: Mutex<Vec<JournalJobRequest>>,
@@ -25,6 +25,7 @@ use workdir::{
WorkdirSessionCapabilities, WorkdirSessionHandle, WriteRequest, WriteResult,
};
use server_api::authorization::PermissionResource;
use server_api::{
WorkingDirectoryCreateRequest as WorkdirCreateRequest,
WorkingDirectoryCreateResponse as WorkdirCreateResponse,
@@ -648,8 +649,74 @@ impl WorkspaceHttpWorkdirBackend {
Ok(workspace_id)
}
fn authorize(&self, operation: &str, resource: &PermissionResource) -> Result<(), ToolError> {
self.client
.authorize_operation(operation, resource)
.map_err(|error| {
let message = error.to_string();
if message.contains("HTTP status 401") || message.contains("HTTP status 403") {
ToolError::StructuredConflict {
code: "workspace_http_403".into(),
message: format!("Host denied {operation} for {resource:?}"),
}
} else {
ToolError::ExecutionFailed(format!(
"Host authorization failed before {operation}: {message}"
))
}
})
}
fn authorize_workspace(&self, operation: &str) -> Result<(), ToolError> {
self.authorize(
operation,
&PermissionResource::Workspace {
workspace_id: self.workspace_id()?.into(),
},
)
}
fn authorize_workdir(&self, operation: &str, id: &str) -> Result<(), ToolError> {
self.authorize(
operation,
&PermissionResource::Workdir {
working_directory_id: id.into(),
path: ".".into(),
recursive: true,
},
)
}
fn authorize_detach(
&self,
alias: &str,
expected_connection_id: Option<&str>,
) -> Result<String, ToolError> {
self.client
.authorize_workdir_attachment_operation("workdir.detach", alias, expected_connection_id)
.map_err(|error| {
let message = error.to_string();
if message.contains("HTTP status 401") || message.contains("HTTP status 403") {
ToolError::StructuredConflict {
code: "workspace_http_403".into(),
message: format!("Host denied workdir.detach for attachment {alias}"),
}
} else if message.contains("HTTP status 409") {
ToolError::StructuredConflict {
code: "workspace_http_409".into(),
message: "attachment lifetime changed before local cleanup".into(),
}
} else {
ToolError::ExecutionFailed(format!(
"Host authorization failed before workdir.detach for {alias}: {message}"
))
}
})
}
fn list(&self) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
self.authorize_workspace("workdir.list")?;
let response = self.execute_json::<WorkdirListResponse>(WorkspaceRequest::get(format!(
"/api/w/{workspace_id}/working-directories"
)))?;
@@ -679,6 +746,13 @@ impl WorkspaceHttpWorkdirBackend {
selector,
operation_id: Some(operation_id),
};
self.authorize(
"workdir.create",
&PermissionResource::Repository {
workspace_id: self.workspace_id()?.into(),
repository_key: repository_key.into(),
},
)?;
let response = self.execute_json::<WorkdirCreateResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/working-directories"),
@@ -720,10 +794,15 @@ impl WorkspaceHttpWorkdirBackend {
response.environment.clone(),
),
) {
let _ = self.detach(alias);
return Err(ToolError::ExecutionFailed(format!(
"attach Workdir session route: {error}"
)));
let rollback = self.detach(alias);
return Err(ToolError::ExecutionFailed(match rollback {
Ok(_) => {
format!("attach Workdir session route: {error}; Backend attachment rolled back")
}
Err(rollback_error) => format!(
"attach Workdir session route: {error}; Backend attachment rollback failed: {rollback_error}; inspect the current attachment before retrying"
),
}));
}
workdir_output(
format!("Attached Workdir {working_directory_id} as {alias}"),
@@ -737,6 +816,7 @@ impl WorkspaceHttpWorkdirBackend {
working_directory_id: &str,
) -> Result<WorkdirSessionAttachmentResponse, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
self.authorize_workdir("workdir.attach", working_directory_id)?;
self.execute_json::<WorkdirSessionAttachmentResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/workers/self/workdir-attachments"),
@@ -759,9 +839,11 @@ impl WorkspaceHttpWorkdirBackend {
) -> Result<ToolOutput, ToolError> {
let workspace_id = encode_path_segment(self.workspace_id()?);
let alias_path = encode_path_segment(alias);
let suffix = expected_connection_id
.map(|id| format!("?expected_connection_id={}", encode_path_segment(id)))
.unwrap_or_default();
let connection_id = self.authorize_detach(alias, expected_connection_id)?;
let suffix = format!(
"?expected_connection_id={}",
encode_path_segment(&connection_id)
);
let response = self.execute_json::<WorkdirAttachmentResponse>(WorkspaceRequest {
method: WorkspaceRequestMethod::Delete,
path: format!(
@@ -786,11 +868,13 @@ impl WorkspaceHttpWorkdirBackend {
)?;
let workspace_id = encode_path_segment(self.workspace_id()?);
let workdir_path = encode_path_segment(workdir_id);
let reason = validate_delete_reason(&input.reason)?;
self.authorize_workdir("workdir.delete", workdir_id)?;
let response = self.execute_json::<WorkdirRemovalResponse>(WorkspaceRequest::json(
WorkspaceRequestMethod::Delete,
format!("/api/w/{workspace_id}/working-directories/{workdir_path}"),
serde_json::to_string(&WorkdirRemovalRequest {
reason: validate_delete_reason(&input.reason)?.to_string(),
reason: reason.to_string(),
})
.map_err(decode_error)?,
))?;
@@ -816,11 +900,20 @@ impl WorkspaceHttpWorkdirBackend {
) -> Result<ToolOutput, ToolError> {
let _mutation_guard = self.attachment_mutation_lock.lock().await;
let alias = validate_identity(alias, DETACH_TOOL, "alias")?;
// Denial must precede local route exclusion and child cleanup as well as DELETE.
let authorized_connection_id = self.authorize_detach(alias, expected_connection_id)?;
if let Some(expected) = expected_connection_id {
// Under the shared local mutation lock, check lifetime before any
// local route exclusion or child cleanup. Backend DELETE still performs
// the authoritative conditional mutation under its lifecycle lock.
let workspace = encode_path_segment(self.workspace_id()?);
self.authorize(
"workdir.attachment.read",
&PermissionResource::WorkdirAttachment {
workspace_id: self.workspace_id()?.into(),
connection_id: expected.into(),
},
)?;
let page: serde_json::Value = self.execute_json(WorkspaceRequest::get(format!(
"/api/w/{workspace}/workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(expected)
@@ -857,7 +950,7 @@ impl WorkspaceHttpWorkdirBackend {
"stop Internal SubWorkers before Workdir detach: {error}"
)));
}
let result = match self.detach_expected(alias, expected_connection_id) {
let result = match self.detach_expected(alias, Some(&authorized_connection_id)) {
Ok(result) => result,
Err(error) => {
self.session_router.cancel_detach(&alias_key);
@@ -1248,6 +1341,10 @@ mod tests {
#[derive(Debug)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
bound_authorizations:
Mutex<Vec<server_api::authorization::BoundOperationAuthorizationRequest>>,
denied: std::sync::atomic::AtomicBool,
responses: Mutex<Vec<WorkspaceResponse>>,
}
@@ -1255,6 +1352,9 @@ mod tests {
fn new(responses: Vec<WorkspaceResponse>) -> Self {
Self {
requests: Mutex::new(Vec::new()),
authorizations: Mutex::default(),
bound_authorizations: Mutex::default(),
denied: std::sync::atomic::AtomicBool::new(false),
responses: Mutex::new(responses.into_iter().rev().collect()),
}
}
@@ -1281,6 +1381,36 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/bound-check") {
let auth: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let connection_id = match &auth.target {
server_api::authorization::BoundAuthorizationTarget::WorkdirAttachment {
expected_connection_id,
..
} => expected_connection_id
.clone()
.unwrap_or_else(|| "fixture-connection".into()),
_ => panic!("unexpected bound target"),
};
self.bound_authorizations.lock().unwrap().push(auth);
return Ok(WorkspaceResponse {
status: if self.denied.load(std::sync::atomic::Ordering::SeqCst) { 403 } else { 200 },
body: json!({"scope":{"kind":"workdir_attachment", "workspace_id":"workspace/test", "connection_id":connection_id}}).to_string(),
});
}
if request.path.ends_with("/authorization/check") {
let auth = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.authorizations.lock().unwrap().push(auth);
return Ok(WorkspaceResponse {
status: if self.denied.load(std::sync::atomic::Ordering::SeqCst) {
403
} else {
200
},
body: r#"{"scope":null}"#.into(),
});
}
self.requests.lock().unwrap().push(request);
self.responses
.lock()
@@ -1627,7 +1757,7 @@ mod tests {
assert_eq!(requests[2].method, WorkspaceRequestMethod::Post);
assert_eq!(
requests[3].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout"
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout?expected_connection_id=fixture-connection"
);
assert_eq!(requests[3].method, WorkspaceRequestMethod::Delete);
assert_eq!(
@@ -2001,7 +2131,7 @@ mod tests {
assert_eq!(client.requests().len(), 1);
assert_eq!(
client.requests()[0].path,
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout"
"/api/w/workspace%2Ftest/workers/self/workdir-attachments/checkout?expected_connection_id=fixture-connection"
);
}
@@ -2194,4 +2324,112 @@ mod tests {
assert_eq!(reopen_calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn host_denial_blocks_lifecycle_requests_and_local_child_cleanup() {
let client = Arc::new(RecordingWorkspaceClient::new(Vec::new()));
client.denied.store(true, Ordering::SeqCst);
let router = Arc::new(workdir::WorkdirSessionRouter::new());
router
.attach(
workdir::WorkdirAttachmentAlias::new("checkout").unwrap(),
WorkspaceAttachedWorkdirSession::handle_for_workdir(
client.clone(),
"checkout",
"wd",
),
)
.unwrap();
let cleanup = Arc::new(AtomicUsize::new(0));
let observed = cleanup.clone();
let backend = WorkspaceHttpWorkdirBackend::new(client.clone())
.with_session_router(router.clone())
.with_child_lifecycle(
Some(Arc::new(move || {
let observed = observed.clone();
Box::pin(async move {
observed.fetch_add(1, Ordering::SeqCst);
Ok(())
})
})),
None,
);
assert!(backend.list().is_err());
assert!(
backend
.create(
WorkdirCreateInput {
runtime_id: None,
repository_key: "repo".into(),
selector: None,
display_name: None,
},
"create-intent".into()
)
.is_err()
);
assert!(
backend
.attach(WorkdirAttachInput {
alias: "new".into(),
working_directory_id: "wd".into()
})
.is_err()
);
assert!(
backend
.delete(WorkdirDeleteInput {
working_directory_id: "wd".into(),
reason: "remove unused".into()
})
.is_err()
);
assert!(backend.detach_managed("checkout", None).await.is_err());
assert!(
backend
.detach_managed("checkout", Some("connection"))
.await
.is_err()
);
assert!(client.requests().is_empty());
assert_eq!(cleanup.load(Ordering::SeqCst), 0);
assert!(router.resolve(Some("checkout")).is_ok());
let checks = client.authorizations.lock().unwrap();
assert_eq!(
checks
.iter()
.map(|r| r.operation.as_str())
.collect::<Vec<_>>(),
[
"workdir.list",
"workdir.create",
"workdir.attach",
"workdir.delete"
]
);
assert_eq!(
checks[1].resource,
PermissionResource::Repository {
workspace_id: "workspace/test".into(),
repository_key: "repo".into()
}
);
assert_eq!(
checks[2].resource,
PermissionResource::Workdir {
working_directory_id: "wd".into(),
path: ".".into(),
recursive: true
}
);
let bound = client.bound_authorizations.lock().unwrap();
assert_eq!(bound.len(), 2);
assert_eq!(bound[1].operation, "workdir.detach");
assert_eq!(
bound[1].target,
server_api::authorization::BoundAuthorizationTarget::WorkdirAttachment {
alias: "checkout".into(),
expected_connection_id: Some("connection".into()),
}
);
}
}
@@ -194,8 +194,29 @@ impl Provider {
))
}
}
fn request(&self, path: &str) -> Result<Json, WipOperationError> {
fn list_operation(domain: Domain) -> &'static str {
match domain {
Domain::Repository => "repository.list",
Domain::Workdir => "workdir.list",
Domain::Attachment => "workdir.attachment.list",
}
}
fn list_request(&self, domain: Domain, path: &str) -> Result<Json, WipOperationError> {
let resource = PermissionResource::Workspace {
workspace_id: self.backend.workspace_id().map_err(map_tool_error)?.into(),
};
self.request(path, Self::list_operation(domain), &resource)
}
fn request(
&self,
path: &str,
operation: &str,
resource: &PermissionResource,
) -> Result<Json, WipOperationError> {
let workspace = self.backend.workspace_id().map_err(map_tool_error)?;
self.backend
.authorize(operation, resource)
.map_err(map_tool_error)?;
let response = self
.backend
.client
@@ -228,11 +249,14 @@ impl Provider {
})
}
fn inventory(&self, domain: Domain) -> Result<Vec<Json>, WipOperationError> {
let response = self.request(match domain {
Domain::Repository => "repositories",
Domain::Workdir => "workers/self/workdir-catalog?limit=1",
Domain::Attachment => "workers/self/workdir-attachments?limit=1",
})?;
let response = self.list_request(
domain,
match domain {
Domain::Repository => "repositories",
Domain::Workdir => "workers/self/workdir-catalog?limit=1",
Domain::Attachment => "workers/self/workdir-attachments?limit=1",
},
)?;
response
.get("items")
.and_then(Json::as_array)
@@ -242,12 +266,24 @@ impl Provider {
})
}
fn raw_item(&self, domain: Domain, id: &str) -> Result<Json, WipOperationError> {
let workspace_id = self
.backend
.workspace_id()
.map_err(map_tool_error)?
.to_owned();
match domain {
Domain::Attachment => self
.request(&format!(
"workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(id)
))?
.request(
&format!(
"workers/self/workdir-attachments?limit=1&connection_id={}",
encode_path_segment(id)
),
"workdir.attachment.read",
&PermissionResource::WorkdirAttachment {
workspace_id,
connection_id: id.into(),
},
)?
.get("items")
.and_then(Json::as_array)
.cloned()
@@ -268,12 +304,34 @@ impl Provider {
} else {
"working-directories"
};
self.request(&format!("{prefix}/{}", encode_path_segment(id)))?
.get("item")
.cloned()
.ok_or_else(|| {
WipOperationError::OutcomeUnknown("invalid Workspace inventory item".into())
})
let (operation, resource) = if matches!(domain, Domain::Repository) {
(
"repository.read",
PermissionResource::Repository {
workspace_id,
repository_key: id.into(),
},
)
} else {
(
"workdir.read",
PermissionResource::Workdir {
working_directory_id: id.into(),
path: ".".into(),
recursive: true,
},
)
};
self.request(
&format!("{prefix}/{}", encode_path_segment(id)),
operation,
&resource,
)?
.get("item")
.cloned()
.ok_or_else(|| {
WipOperationError::OutcomeUnknown("invalid Workspace inventory item".into())
})
}
}
}
@@ -369,11 +427,11 @@ impl Provider {
if self.config_content
&& raw.get("alias").and_then(Json::as_str)
== Some(crate::feature::builtin::workspace_config::ATTACHMENT_ALIAS)
&& let Ok(Some(current)) =
&& let Some(current) =
crate::feature::builtin::workspace_config::backend::WorkspaceConfigBackend::new(
self.backend.client.clone(),
)
.current()
.current()?
&& current.connection_id == id
&& raw.get("working_directory_id").and_then(Json::as_str)
== Some(current.working_directory_id.as_str())
@@ -429,7 +487,7 @@ impl WipOperationHandler for CollectionHandler {
};
match self
.provider
.request(path)
.list_request(self.domain, path)
.ok()
.and_then(|p| p.get("digest").and_then(Json::as_str).map(str::to_owned))
{
@@ -523,7 +581,7 @@ impl WipOperationHandler for CollectionHandler {
.unwrap_or_default();
format!("workers/self/workdir-catalog?limit={limit}{suffix}")
};
let page = self.provider.request(&path)?;
let page = self.provider.list_request(self.domain, &path)?;
let raws = page
.get("items")
.and_then(Json::as_array)
@@ -1083,6 +1141,10 @@ pub(crate) mod tests {
pub(crate) workdirs: Vec<Json>,
pub(crate) attachments: Vec<Json>,
pub(crate) requests: Vec<WorkspaceRequest>,
pub(crate) authorizations: Vec<server_api::authorization::OperationAuthorizationRequest>,
pub(crate) bound_authorizations:
Vec<server_api::authorization::BoundOperationAuthorizationRequest>,
pub(crate) authorization_denied: bool,
pub(crate) denied: bool,
config_current: Option<Json>,
generation: usize,
@@ -1097,6 +1159,9 @@ pub(crate) mod tests {
workdirs: vec![],
attachments: vec![],
requests: vec![],
authorizations: vec![],
bound_authorizations: vec![],
authorization_denied: false,
denied: false,
config_current: None,
generation: 0,
@@ -1122,6 +1187,41 @@ pub(crate) mod tests {
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let mut state = self.state.lock().unwrap();
if request.path.ends_with("/authorization/bound-check") {
let auth: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let connection = match &auth.target {
server_api::authorization::BoundAuthorizationTarget::WorkdirAttachment {
alias,
expected_connection_id,
} => state
.attachments
.iter()
.find(|item| {
item["alias"].as_str() == Some(alias.as_str())
&& expected_connection_id
.as_deref()
.is_none_or(|id| item["connection_id"].as_str() == Some(id))
})
.and_then(|item| item["connection_id"].as_str())
.map(str::to_owned),
_ => panic!("unexpected bound target"),
};
state.bound_authorizations.push(auth);
return Ok(WorkspaceResponse {
status: if state.authorization_denied { 403 } else if connection.is_none() { 409 } else { 200 },
body: json!({"scope":{"kind":"workdir_attachment", "workspace_id":"test-workspace", "connection_id":connection.unwrap_or_default()}}).to_string(),
});
}
if request.path.ends_with("/authorization/check") {
state
.authorizations
.push(serde_json::from_str(request.body.as_deref().unwrap()).unwrap());
return Ok(WorkspaceResponse {
status: if state.authorization_denied { 403 } else { 200 },
body: r#"{"scope":null}"#.into(),
});
}
state.requests.push(request.clone());
if state.denied {
return Ok(WorkspaceResponse {
@@ -1331,13 +1431,7 @@ pub(crate) mod tests {
.is_none()
);
client.state.lock().unwrap().denied = true;
assert!(
p.projected(Domain::Attachment, &raw)
.ok()
.unwrap()
.get("content_path")
.is_none()
);
assert!(p.projected(Domain::Attachment, &raw).is_err());
}
#[test]
@@ -1674,6 +1768,52 @@ pub(crate) mod tests {
Err(WipMountError::OperationCollision { .. })
));
}
#[tokio::test]
async fn wip_inventory_and_dynamic_items_recheck_authority_before_reads() {
let client = Arc::new(CatalogClient::default());
let p = provider(client.clone(), true, true, None);
client.state.lock().unwrap().authorization_denied = true;
for domain in [Domain::Repository, Domain::Workdir, Domain::Attachment] {
assert!(p.inventory(domain).is_err());
assert!(p.raw_item(domain, "target").is_err());
let handler = CollectionHandler {
provider: p.clone(),
domain,
};
assert!(
handler
.call("list", &BTreeMap::new(), context())
.await
.is_err()
);
let resolver = ItemResolver {
provider: p.clone(),
domain,
};
assert!(resolver.resolve(&encode_identity("target")).is_none());
}
assert!(client.state.lock().unwrap().requests.is_empty());
client.state.lock().unwrap().authorization_denied = false;
let raw = p
.raw_item(Domain::Repository, "registered-key")
.ok()
.unwrap();
assert_eq!(raw["repository_key"], "registered-key");
let reads = client.state.lock().unwrap().requests.len();
client.state.lock().unwrap().authorization_denied = true;
assert!(p.raw_item(Domain::Repository, "registered-key").is_err());
assert_eq!(client.state.lock().unwrap().requests.len(), reads);
let state = client.state.lock().unwrap();
let last = state.authorizations.last().unwrap();
assert_eq!(last.operation, "repository.read");
assert_eq!(
last.resource,
PermissionResource::Repository {
workspace_id: "test-workspace".into(),
repository_key: "registered-key".into()
}
);
}
}
#[cfg(test)]
@@ -8,6 +8,7 @@ use schemars::JsonSchema;
use serde::Deserialize;
use protocol::Segment;
use server_api::authorization::PermissionResource;
use server_api::{
BrowserWorkerWorkingDirectorySelection, CreateWorkspaceWorkerRequest,
CreateWorkspaceWorkerTicketAssignmentRequest,
@@ -104,28 +105,59 @@ struct WorkspaceWorkerControlService {
}
impl WorkspaceWorkerControlService {
fn known_subworkers(&self) -> Vec<serde_json::Value> {
self.registry
.as_ref()
.map(|registry| {
registry
.list_internal()
.into_iter()
.map(|internal| {
serde_json::json!({
"subject": { "kind": "sub_worker", "name": internal.worker_name },
"relation": "direct_child",
"origin": "sub_worker_spawn",
"permissions": ["send_input", "stop", "observe"],
"summary": {
"display_name": internal.worker_name,
"status": format!("{:?}", internal.session.status()).to_lowercase(),
}
})
})
.collect()
fn authorize_runtime_worker(
&self,
operation: &str,
runtime_id: &str,
worker_id: &str,
) -> Result<(), WorkspaceClientError> {
if !self.runtime_worker_control {
return Err(WorkspaceClientError::Unavailable(
"Runtime Worker control is not enabled for this Worker".into(),
));
}
self.client.authorize_operation(
operation,
&PermissionResource::Worker {
runtime_id: runtime_id.into(),
worker_id: worker_id.into(),
},
)
}
fn authorize_subworker(&self, operation: &str, name: &str) -> Result<(), WorkspaceClientError> {
// Explicit standalone sessions retain parent-owned local child control.
// A failed or incomplete Workspace binding must never take this path.
if !self.client.is_available()
&& self.client.workspace_id().is_none()
&& self.client.kind() == "unavailable"
{
return Ok(());
}
self.client.authorize_subworker_operation(operation, name)
}
fn known_subworkers(&self) -> Result<Vec<serde_json::Value>, WorkspaceClientError> {
let Some(registry) = &self.registry else {
return Ok(Vec::new());
};
registry
.list_internal()
.into_iter()
.map(|internal| {
self.authorize_subworker("subworker.observe", &internal.worker_name)?;
Ok(serde_json::json!({
"subject": { "kind": "sub_worker", "name": internal.worker_name },
"relation": "direct_child",
"origin": "sub_worker_spawn",
"permissions": ["send_input", "stop", "observe"],
"summary": {
"display_name": internal.worker_name,
"status": format!("{:?}", internal.session.status()).to_lowercase(),
}
}))
})
.unwrap_or_default()
.collect()
}
}
@@ -147,6 +179,12 @@ impl WorkerControlService for WorkspaceWorkerControlService {
async fn list_workers(&self) -> Result<WorkspaceResponse, WorkspaceClientError> {
let response = if self.runtime_worker_control {
self.client.authorize_operation(
"worker.list",
&PermissionResource::Workspace {
workspace_id: self.workspace_id.clone(),
},
)?;
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlList,
)?
@@ -200,7 +238,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
.map(serde_json::Value::to_string)
.is_none_or(|subject| seen.insert(subject))
});
for worker in self.known_subworkers() {
for worker in self.known_subworkers()? {
let is_new = worker
.get("subject")
.map(serde_json::Value::to_string)
@@ -227,6 +265,14 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
} => {
self.authorize_runtime_worker(
match kind {
WorkerControlInputKind::User => "worker.send_input",
WorkerControlInputKind::Notify => "worker.notify",
},
&runtime_id,
&worker_id,
)?;
let kind = match kind {
WorkerControlInputKind::User => "user",
WorkerControlInputKind::Notify => "notify",
@@ -292,6 +338,14 @@ impl WorkerControlService for WorkspaceWorkerControlService {
runtime_id,
worker_id,
} => {
self.authorize_runtime_worker(
match kind {
WorkerControlStopKind::Cancel => "worker.cancel",
WorkerControlStopKind::Stop => "worker.stop",
},
&runtime_id,
&worker_id,
)?;
let operation = match kind {
WorkerControlStopKind::Cancel => {
crate::worker::WorkspaceServerOperation::WorkerControlCancel {
@@ -374,6 +428,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
self.authorize_runtime_worker("worker.restore", &runtime_id, &worker_id)?;
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlRestore {
runtime_id,
@@ -394,6 +449,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
self.authorize_runtime_worker("worker.remove", runtime_id, worker_id)?;
self.client
.execute_worker_remove(runtime_id, worker_id, reason)
}
@@ -411,7 +467,7 @@ impl WorkerControlService for WorkspaceWorkerControlService {
.and_then(|registry| registry.get_internal(name))
.is_some();
if known && matches!(permission, "send_input" | "stop" | "observe") {
Ok(())
self.authorize_subworker(&format!("subworker.{permission}"), name)
} else {
Err(WorkspaceClientError::Request(
"unknown Worker or permission not granted".to_string(),
@@ -427,6 +483,11 @@ impl WorkerControlService for WorkspaceWorkerControlService {
"Runtime Worker control is not enabled for this Worker".to_string(),
));
}
self.authorize_runtime_worker(
&format!("worker.{permission}"),
runtime_id,
worker_id,
)?;
let response = self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlList,
)?;
@@ -566,6 +627,15 @@ impl WorkerLifecycleService for WorkspaceWorkerLifecycleService {
request: WorkerLifecycleSpawnRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let body = workspace_worker_create_request(request)?;
let workspace_id = self.client.workspace_id().ok_or_else(|| {
WorkspaceClientError::Unavailable("Worker creation requires Workspace identity".into())
})?;
self.client.authorize_operation(
"worker.create",
&PermissionResource::Workspace {
workspace_id: workspace_id.into(),
},
)?;
self.client.execute_server_operation(
crate::worker::WorkspaceServerOperation::WorkerControlSpawn(body),
)
@@ -1321,6 +1391,8 @@ mod tests {
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
requests: Mutex<Vec<WorkspaceRequest>>,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
denied: std::sync::atomic::AtomicBool,
removals: Mutex<Vec<(String, String, String)>>,
}
@@ -1341,6 +1413,18 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let auth = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.authorizations.lock().unwrap().push(auth);
return Ok(WorkspaceResponse {
status: if self.denied.load(std::sync::atomic::Ordering::SeqCst) {
403
} else {
200
},
body: r#"{"scope":null}"#.into(),
});
}
let is_worker_list = request.path.ends_with("/worker-control/workers");
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
@@ -2332,4 +2416,166 @@ mod tests {
assert!(validate_relative_cwd("/repo").is_err());
assert_eq!(validate_relative_cwd("repo/src").unwrap(), "repo/src");
}
// Connection: production control/lifecycle services and WorkspaceClient authorization
// transport; only the Host verdict and downstream Backend are fixture boundaries.
#[tokio::test]
async fn every_runtime_control_effect_rechecks_host_before_backend_dispatch() {
async fn call(
control: &dyn WorkerControlService,
op: &str,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let subject = WorkerControlSubject::RuntimeWorker {
runtime_id: "target-runtime".into(),
worker_id: "target-worker".into(),
};
match op {
"worker.list" => control.list_workers().await,
"worker.send_input" | "worker.notify" => {
control
.send_input(
subject,
"input".into(),
if op == "worker.notify" {
WorkerControlInputKind::Notify
} else {
WorkerControlInputKind::User
},
)
.await
}
"worker.cancel" | "worker.stop" => {
control
.stop_worker(
subject,
if op == "worker.cancel" {
WorkerControlStopKind::Cancel
} else {
WorkerControlStopKind::Stop
},
None,
)
.await
}
"worker.restore" => {
control
.restore_worker(
"target-runtime".into(),
"target-worker".into(),
server_api::WorkerRestoreRequest {
request_id: "restore-intent".into(),
expected_observation_token: "observed".into(),
},
)
.await
}
"worker.remove" => control.remove_runtime_worker(
"target-runtime",
"target-worker",
"requested removal",
),
"worker.create" => {
control
.spawn_worker(WorkerLifecycleSpawnRequest {
orchestrator_assignment_id: None,
runtime_id: "target-runtime".into(),
workdir_attachments: vec![],
profile: "builtin:coder".into(),
singleton_key: None,
ticket_id: None,
operation_id: Some("spawn-intent".into()),
execution_grant: None,
display_name: "child".into(),
initial_submit: vec![Segment::text("work")],
})
.await
}
_ => unreachable!(),
}
}
for op in [
"worker.list",
"worker.send_input",
"worker.notify",
"worker.cancel",
"worker.stop",
"worker.restore",
"worker.remove",
"worker.create",
] {
let client = Arc::new(RecordingWorkspaceClient::default());
let control = test_control(client.clone());
client
.denied
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(call(control.as_ref(), op).await.is_err(), "{op}");
assert!(client.requests.lock().unwrap().is_empty(), "{op}");
assert!(client.removals.lock().unwrap().is_empty(), "{op}");
client
.denied
.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(
call(control.as_ref(), op).await.unwrap().is_success(),
"{op}"
);
let effects =
client.requests.lock().unwrap().len() + client.removals.lock().unwrap().len();
assert_eq!(effects, 1, "{op}");
client
.denied
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(call(control.as_ref(), op).await.is_err(), "{op}");
assert_eq!(
client.requests.lock().unwrap().len() + client.removals.lock().unwrap().len(),
effects,
"{op}"
);
let checks = client.authorizations.lock().unwrap();
assert_eq!(checks.len(), 3, "{op}");
for check in checks.iter() {
assert_eq!(check.operation, op);
assert_eq!(
check.resource,
if matches!(op, "worker.list" | "worker.create") {
PermissionResource::Workspace {
workspace_id: if op == "worker.list" {
"workspace%2Ftest"
} else {
"workspace/test"
}
.into(),
}
} else {
PermissionResource::Worker {
runtime_id: "target-runtime".into(),
worker_id: "target-worker".into(),
}
}
);
}
}
}
#[test]
fn only_explicit_standalone_retains_local_subworker_authority_without_host() {
for workspace_id in [None, Some("bound-workspace".to_string())] {
let identity = workspace_id
.as_ref()
.map(|id| crate::worker::WorkspaceId::new(id.clone()).unwrap());
let client = crate::worker::unavailable_workspace_client(
identity.as_ref(),
"unavailable fixture",
);
let control = WorkspaceWorkerControlService {
client,
workspace_id: workspace_id.clone().unwrap_or_default(),
registry: None,
runtime_worker_control: false,
};
assert_eq!(
control
.authorize_subworker("subworker.stop", "child")
.is_ok(),
workspace_id.is_none()
);
}
}
}
+476 -70
View File
@@ -242,6 +242,27 @@ impl dyn WorkspaceClient + '_ {
#[cfg(test)]
const RESIDENT_SUMMARY_REQUEST_TIMEOUT: Duration = Duration::from_secs(5);
pub(crate) fn subject_memory_operation_name(
operation: &server_api::SubjektivMemoryBackendOperation,
) -> &'static str {
use server_api::SubjektivMemoryBackendOperation as Operation;
match operation {
Operation::ResidentSummary(_) | Operation::ResidentContext(_) => "memory.resident.read",
Operation::Query(_) => "memory.query",
Operation::Read(_) => "memory.read",
Operation::ListChanges(_) => "memory.changes.list",
Operation::ValidateProposal(_) => "memory.proposal.validate",
Operation::ReceiptStatus(_) => "memory.receipt.read",
Operation::StageExplicit(_) => "memory.staging.write",
Operation::ListCandidates(_) => "memory.staging.list",
Operation::ReadCandidate(_) => "memory.staging.read",
Operation::DecideCandidate(_) => "memory.candidate.decide",
Operation::PrepareSurface(_) => "memory.surface.prepare",
Operation::PublishSurface(_) => "memory.surface.publish",
Operation::FailSurface(_) => "memory.surface.fail",
}
}
async fn execute_subjektiv_memory_operation(
client: &dyn WorkspaceClient,
operation: server_api::SubjektivMemoryBackendOperation,
@@ -268,6 +289,7 @@ fn execute_subjektiv_memory_operation_with(
client.kind()
),
})?;
client.authorize_subject_operation(subject_memory_operation_name(&operation))?;
let response = execute(
client,
WorkspaceRequest::json(
@@ -287,6 +309,40 @@ fn execute_subjektiv_memory_operation_with(
Ok(serde_json::from_str(&response.body)?)
}
pub(super) fn authorize_workspace_memory_operation(
client: &dyn WorkspaceClient,
operation: &str,
) -> Result<(), WorkspaceMemoryBackendError> {
let workspace_id =
client
.workspace_id()
.ok_or_else(|| WorkspaceMemoryBackendError::Unavailable {
reason: "Memory operation requires a Host-bound Workspace identity".into(),
})?;
client.authorize_operation(
operation,
&server_api::authorization::PermissionResource::Workspace {
workspace_id: workspace_id.to_owned(),
},
)?;
Ok(())
}
fn memory_operation_name(operation: &MemoryBackendOperation) -> &'static str {
match operation {
MemoryBackendOperation::Query(_) => "memory.query",
MemoryBackendOperation::ReadDocument(_) => "memory.document.read",
MemoryBackendOperation::UpdateDocument(_) => "memory.document.update",
MemoryBackendOperation::ResidentSummary(_) => "memory.resident.read",
MemoryBackendOperation::AppendAudit(_) => "memory.audit.append",
MemoryBackendOperation::StageCandidate(_) => "memory.staging.write",
MemoryBackendOperation::StageExtracted(_) => "memory.staging.write",
MemoryBackendOperation::StagingList(_) => "memory.staging.list",
MemoryBackendOperation::StagingRead(_) => "memory.staging.read",
MemoryBackendOperation::StagingClose(_) => "memory.staging.close",
}
}
async fn execute_memory_backend(
client: &dyn WorkspaceClient,
operation: MemoryBackendOperation,
@@ -300,6 +356,7 @@ async fn execute_memory_backend(
client.kind()
),
})?;
authorize_workspace_memory_operation(client, memory_operation_name(&operation))?;
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("/api/w/{workspace_id}/memory/backend"),
@@ -335,6 +392,11 @@ async fn execute_memory_consolidation(
client.kind()
),
})?;
if subjektiv {
client.authorize_subject_operation("memory.consolidation.request")?;
} else {
authorize_workspace_memory_operation(client, "memory.consolidation.request")?;
}
let response = client.execute(WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!(
@@ -514,6 +576,8 @@ impl Tool for SubjectConsolidationTool {
)?)
}
};
self.host
.authorize_operation(subject_memory_operation_name(&operation))?;
let response = match self.host.memory(operation) {
Ok(response) => response,
Err(SubjektivHostError::Conflict { code, message }) => {
@@ -713,8 +777,32 @@ fn query_schema() -> serde_json::Value {
})
}
#[derive(Default, PartialEq, Eq)]
enum ResidentRepresentation {
#[default]
Unknown,
NotStarted,
Supplied,
}
struct WorkspaceResidentSummarySource {
client: Arc<dyn WorkspaceClient>,
// Unknown/restored prompts must be checked. Only an explicitly unstarted
// initial contribution can omit checks when no protected content was supplied.
representation: Mutex<ResidentRepresentation>,
}
impl WorkspaceResidentSummarySource {
fn not_started(&self) -> SystemPromptContribution {
let mut representation = self
.representation
.lock()
.expect("resident representation poisoned");
if *representation == ResidentRepresentation::Unknown {
*representation = ResidentRepresentation::NotStarted;
}
SystemPromptContribution::Unavailable
}
}
#[derive(Default)]
@@ -739,7 +827,39 @@ fn resident_summary_contribution(
#[async_trait]
impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
fn protected_input_kind(&self) -> Option<&'static str> {
match *self
.representation
.lock()
.expect("resident representation poisoned")
{
ResidentRepresentation::NotStarted => None,
ResidentRepresentation::Unknown | ResidentRepresentation::Supplied => {
Some("workspace-memory")
}
}
}
async fn authorize_supply(&self) -> Result<(), String> {
if *self
.representation
.lock()
.expect("resident representation poisoned")
== ResidentRepresentation::NotStarted
{
return Ok(());
}
authorize_workspace_memory_operation(self.client.as_ref(), "memory.resident.supply")
.map_err(|error| error.to_string())
}
async fn load(&self) -> SystemPromptContribution {
if let Err(error) =
authorize_workspace_memory_operation(self.client.as_ref(), "memory.resident.supply")
{
tracing::warn!(%error, operation = "memory.resident.supply", "optional resident Memory not started; no protected read or prompt contribution");
return self.not_started();
}
match self
.client
.execute_memory_backend_operation(
@@ -750,15 +870,32 @@ impl SystemPromptContributionSource for WorkspaceResidentSummarySource {
.await
{
Ok(memory::backend::MemoryBackendOperationResult::ResidentSummary(output)) => {
resident_summary_contribution(output)
if let Err(error) = authorize_workspace_memory_operation(
self.client.as_ref(),
"memory.resident.supply",
) {
tracing::warn!(%error, operation = "memory.resident.supply", "optional resident Memory prompt contribution not supplied");
return self.not_started();
}
let contribution = resident_summary_contribution(output);
if matches!(contribution, SystemPromptContribution::Ready(_)) {
*self
.representation
.lock()
.expect("resident representation poisoned") =
ResidentRepresentation::Supplied;
} else {
self.not_started();
}
contribution
}
Ok(other) => {
tracing::debug!(?other, "unexpected resident Memory Backend result");
SystemPromptContribution::Unavailable
self.not_started()
}
Err(error) => {
tracing::debug!(%error, "resident Memory summary unavailable");
SystemPromptContribution::Unavailable
tracing::warn!(%error, operation = "memory.resident.read", "optional resident Memory not started; protected read failed and no prompt contribution supplied");
self.not_started()
}
}
}
@@ -775,6 +912,7 @@ impl WorkspaceSubjektivResidentSummarySource {
async fn fetch(
&self,
) -> Result<server_api::SubjektivResidentContextOutput, SubjektivHostError> {
self.host.authorize_operation("memory.resident.read")?;
match self.host.memory(
server_api::SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
)? {
@@ -826,6 +964,16 @@ impl WorkspaceSubjektivResidentSummarySource {
/// Invalidate applied before rewrite so identical bodies can be represented again.
#[async_trait]
impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource {
fn protected_input_kind(&self) -> Option<&'static str> {
Some("subject-resident")
}
async fn authorize_supply(&self) -> Result<(), String> {
self.host
.authorize_operation("memory.resident.supply")
.map_err(|error| error.to_string())
}
async fn load(&self) -> SystemPromptContribution {
self.load_initial().await.0
}
@@ -836,36 +984,45 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
SystemPromptContribution,
Option<crate::worker::PromptContributionCompletion>,
) {
match self.fetch().await {
Ok(output) => match self.render(&output) {
Ok((rendered, _)) => {
let state = self.behavior_state.clone();
let behavior = output.behavior_md;
(
SystemPromptContribution::Ready(rendered),
Some(Box::new(move || {
state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior);
})),
)
}
Err(error) => {
tracing::error!(%error, "subject resident context render failed");
(SystemPromptContribution::Ready("The Host could not render the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
}
},
match self.load_initial_checked().await {
Ok(contribution) => contribution,
Err(error) => {
tracing::error!(%error, "subject resident context fetch failed");
(SystemPromptContribution::Ready("The Host could not fetch the Subject resident context. Do not treat this as unset behavior or infer replacement behavior from Memory.".into()), None)
tracing::error!(%error, "required Subject resident context not started; no prompt supplied");
(SystemPromptContribution::Unavailable, None)
}
}
}
async fn load_initial_checked(
&self,
) -> Result<
(
SystemPromptContribution,
Option<crate::worker::PromptContributionCompletion>,
),
String,
> {
self.authorize_supply().await?;
let output = self.fetch().await.map_err(|error| error.to_string())?;
let (rendered, _) = self.render(&output)?;
self.authorize_supply().await?;
let state = self.behavior_state.clone();
let behavior = output.behavior_md;
Ok((
SystemPromptContribution::Ready(rendered),
Some(Box::new(move || {
state
.lock()
.expect("subject behavior state poisoned")
.applied = Some(behavior);
})),
))
}
async fn load_changed_resident_context(
&self,
) -> Result<Option<ResidentContextRefresh>, String> {
self.authorize_supply().await?;
let output = self.fetch().await.map_err(|error| error.to_string())?;
let changed = self
.behavior_state
@@ -878,6 +1035,7 @@ impl SystemPromptContributionSource for WorkspaceSubjektivResidentSummarySource
return Ok(None);
}
let (rendered, prompt_provenance) = self.render(&output)?;
self.authorize_supply().await?;
Ok(Some(ResidentContextRefresh {
body: rendered,
behavior_md: output.behavior_md,
@@ -1074,7 +1232,15 @@ struct SubjektivResidentRestoreRefreshHook {
#[async_trait]
impl Hook<WorkerRestored> for SubjektivResidentRestoreRefreshHook {
async fn call(&self, context: &crate::hook::WorkerRestoredContext) -> Result<(), HookError> {
let contribution = self.source.load().await;
let (contribution, _) = self
.source
.load_initial_checked()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
self.source
.authorize_supply()
.await
.map_err(|error| HookError::new(HookErrorCategory::Dependency, error))?;
let (surface_ready, summary) = match &contribution {
SystemPromptContribution::Ready(summary) => (true, Some(summary.as_str())),
SystemPromptContribution::Unavailable => (false, None),
@@ -1164,6 +1330,7 @@ impl MemoryFeatureInstallPlan {
let resident_summary_source = config.profile.resident.inject_summary.then(|| {
Arc::new(WorkspaceResidentSummarySource {
representation: Mutex::new(ResidentRepresentation::Unknown),
client: Arc::clone(&client),
}) as Arc<dyn SystemPromptContributionSource>
});
@@ -1388,7 +1555,7 @@ mod tests {
struct SubjectResidentClient {
availability: memory::backend::MemoryResidentSummaryAvailability,
content: Option<String>,
scope_allowed: bool,
scope_allowed: std::sync::atomic::AtomicBool,
behavior: Mutex<String>,
paths: Mutex<Vec<String>>,
timeouts: Mutex<Vec<Duration>>,
@@ -1402,7 +1569,7 @@ mod tests {
Self {
availability,
content: content.map(str::to_string),
scope_allowed: true,
scope_allowed: std::sync::atomic::AtomicBool::new(true),
behavior: Mutex::new("Be deliberate.".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
@@ -1413,7 +1580,7 @@ mod tests {
Self {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("must not be injected".into()),
scope_allowed: false,
scope_allowed: std::sync::atomic::AtomicBool::new(false),
behavior: Mutex::new("must not be injected".to_string()),
paths: Mutex::new(Vec::new()),
timeouts: Mutex::new(Vec::new()),
@@ -1438,6 +1605,22 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/bound-check") {
let request: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(matches!(
request.target,
server_api::authorization::BoundAuthorizationTarget::Subject {}
));
return Ok(WorkspaceResponse {
status: if self.scope_allowed.load(std::sync::atomic::Ordering::SeqCst) {
200
} else {
403
},
body: "{}".into(),
});
}
self.paths.lock().unwrap().push(request.path.clone());
assert!(request.path.ends_with("/subjektiv/memory"));
let request: server_api::SubjektivMemoryBackendRequest =
@@ -1446,7 +1629,7 @@ mod tests {
request.operation,
server_api::SubjektivMemoryBackendOperation::ResidentContext(_)
));
if !self.scope_allowed {
if !self.scope_allowed.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(WorkspaceResponse {
status: 403,
body: "subject scope unavailable".into(),
@@ -1475,7 +1658,9 @@ mod tests {
request: WorkspaceRequest,
timeout: Duration,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
self.timeouts.lock().unwrap().push(timeout);
if !request.path.contains("/authorization/") {
self.timeouts.lock().unwrap().push(timeout);
}
self.execute(request)
}
}
@@ -1498,24 +1683,20 @@ mod tests {
let addr = listener.local_addr().unwrap();
let content = content.map(str::to_string);
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 1024];
let _ = stream.read(&mut request).unwrap();
let body = serde_json::json!({
"status": "ok",
"result": {
"kind": "resident_summary",
"availability": availability,
"content": content,
}
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
for _ in 0..4 {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let size = stream.read(&mut request).unwrap();
let body = if String::from_utf8_lossy(&request[..size]).contains("/authorization/check") {
serde_json::json!({"scope":{"kind":"workspace","workspace_id":"workspace"}})
} else {
serde_json::json!({
"status": "ok",
"result": { "kind": "resident_summary", "availability": availability, "content": content }
})
}.to_string();
write!(stream, "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}", body.len()).unwrap();
}
});
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -1540,15 +1721,22 @@ mod tests {
})
.to_string();
std::thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let _ = stream.read(&mut request).unwrap();
let response = format!(
"HTTP/1.1 {status} rejected\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{}",
body.len(),
body
);
stream.write_all(response.as_bytes()).unwrap();
loop {
let (mut stream, _) = listener.accept().unwrap();
let mut request = [0_u8; 4096];
let size = stream.read(&mut request).unwrap();
let is_authorization =
String::from_utf8_lossy(&request[..size]).contains("/authorization/");
let (status, response_body) = if is_authorization {
(200, "{}")
} else {
(status, body.as_str())
};
write!(stream, "HTTP/1.1 {status} response\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{response_body}", response_body.len()).unwrap();
if !is_authorization {
break;
}
}
});
Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
@@ -2001,14 +2189,12 @@ permission = "write"
.unwrap()
.unwrap();
let SystemPromptContribution::Ready(failure) = source.load().await else {
panic!("fetch failure must be explicit in resident context");
let error = match source.load_initial_checked().await {
Err(error) => error,
Ok(_) => panic!("required resident fetch must fail startup"),
};
assert!(failure.contains("could not fetch"));
assert_eq!(
client.paths.lock().unwrap().clone(),
vec!["/api/w/workspace/subjektiv/memory".to_string()]
);
assert!(error.contains("403"));
assert_eq!(client.paths.lock().unwrap().clone(), Vec::<String>::new());
}
#[test]
@@ -2095,6 +2281,221 @@ permission = "write"
);
}
#[derive(Debug)]
struct MemoryAuthorizationFixture {
status: std::sync::atomic::AtomicU16,
effects: std::sync::atomic::AtomicUsize,
checks: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
}
impl MemoryAuthorizationFixture {
fn new(status: u16) -> Self {
Self {
status: std::sync::atomic::AtomicU16::new(status),
effects: std::sync::atomic::AtomicUsize::new(0),
checks: Mutex::new(Vec::new()),
}
}
}
// Exercise the production authorization request adapter and Memory dispatcher;
// only the Host transport/provider effect is replaced.
impl WorkspaceClient for MemoryAuthorizationFixture {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"memory-authorization-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
use std::sync::atomic::Ordering;
if request.path.ends_with("/authorization/check") {
let check = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.checks.lock().unwrap().push(check);
let status = self.status.load(Ordering::SeqCst);
if status == 0 {
return Err(WorkspaceClientError::Unavailable(
"authorization transport offline".into(),
));
}
return Ok(WorkspaceResponse {
status,
body: if status == 200 {
r#"{"scope":{"kind":"workspace","workspace_id":"workspace"}}"#.into()
} else {
r#"{"message":"operation not granted"}"#.into()
},
});
}
assert!(request.path.ends_with("/memory/backend"));
let operation: server_api::MemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.effects.fetch_add(1, Ordering::SeqCst);
let result = if matches!(operation.0, MemoryBackendOperation::ResidentSummary(_)) {
MemoryBackendOperationResult::ResidentSummary(
memory::backend::MemoryResidentSummaryOutput {
availability: memory::backend::MemoryResidentSummaryAvailability::Ready,
content: Some("protected summary".into()),
},
)
} else {
MemoryBackendOperationResult::ToolOutput(MemoryToolOutput {
summary: "effect completed".into(),
content: None,
})
};
Ok(WorkspaceResponse {
status: 200,
body: serde_json::to_string(&MemoryBackendHttpResponse::Ok { result }).unwrap(),
})
}
}
#[tokio::test]
async fn resident_read_denied_before_provider_access_and_prompt_supply() {
use std::sync::atomic::Ordering;
let client = Arc::new(MemoryAuthorizationFixture::new(403));
let source = WorkspaceResidentSummarySource {
representation: Mutex::new(ResidentRepresentation::Unknown),
client: client.clone(),
};
assert_eq!(source.load().await, SystemPromptContribution::Unavailable);
assert_eq!(client.effects.load(Ordering::SeqCst), 0);
let checks = client.checks.lock().unwrap();
assert_eq!(checks[0].operation, "memory.resident.supply");
assert_eq!(
checks[0].resource,
server_api::authorization::PermissionResource::Workspace {
workspace_id: "workspace".into()
}
);
}
#[tokio::test]
async fn resident_cached_supply_rechecks_authority_but_unstarted_optional_source_supplies_nothing()
{
use std::sync::atomic::Ordering;
let client = Arc::new(MemoryAuthorizationFixture::new(200));
let source = WorkspaceResidentSummarySource {
representation: Mutex::new(ResidentRepresentation::Unknown),
client: client.clone(),
};
assert!(matches!(
source.load().await,
SystemPromptContribution::Ready(_)
));
client.status.store(403, Ordering::SeqCst);
assert!(source.authorize_supply().await.unwrap_err().contains("403"));
assert_eq!(client.effects.load(Ordering::SeqCst), 1);
let never_started = WorkspaceResidentSummarySource {
representation: Mutex::new(ResidentRepresentation::Unknown),
client: client.clone(),
};
// Unknown includes restore: saved protected content must not bypass checks.
assert!(never_started.authorize_supply().await.is_err());
assert_eq!(
never_started.load().await,
SystemPromptContribution::Unavailable
);
// Optional initial refusal left no protected content to supply.
never_started.authorize_supply().await.unwrap();
assert_eq!(client.effects.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn subject_cached_and_changed_resident_supply_reject_revocation_without_new_reads() {
let manifest = subject_manifest("workspace");
let client = Arc::new(SubjectResidentClient::new(
memory::backend::MemoryResidentSummaryAvailability::Ready,
Some("private surface"),
));
let source = backend_resident_source(&manifest, client.clone(), test_prompts())
.unwrap()
.unwrap();
let (initial, completion) = source.load_initial_checked().await.unwrap();
assert!(matches!(initial, SystemPromptContribution::Ready(_)));
completion.unwrap()();
assert_eq!(client.paths.lock().unwrap().len(), 1);
client
.scope_allowed
.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(
source
.authorize_supply()
.await
.unwrap_err()
.contains("denied")
);
assert!(source.load_changed_resident_context().await.is_err());
assert_eq!(client.paths.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn memory_tool_rechecks_current_authority_before_each_effect() {
use std::sync::atomic::Ordering;
let client = Arc::new(MemoryAuthorizationFixture::new(200));
let (_, tool) = workspace_http_memory_tools(client.clone())
.into_iter()
.map(|definition| definition())
.find(|(meta, _)| meta.name == "MemoryReadDocument")
.unwrap();
tool.execute("{}", ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(client.effects.load(Ordering::SeqCst), 1);
client.status.store(403, Ordering::SeqCst);
let error = tool
.execute("{}", ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains("403"));
assert_eq!(client.effects.load(Ordering::SeqCst), 1);
assert_eq!(client.checks.lock().unwrap().len(), 2);
}
#[tokio::test]
async fn memory_authorization_transport_failure_is_not_permission_denial() {
use std::sync::atomic::Ordering;
let client = MemoryAuthorizationFixture::new(0);
let error = execute_memory_backend(
&client,
MemoryBackendOperation::ReadDocument(MemoryDocumentReadOperation {
offset: None,
limit: None,
}),
)
.await
.unwrap_err();
assert!(matches!(
error,
WorkspaceMemoryBackendError::Request(WorkspaceClientError::Unavailable(_))
));
assert_eq!(client.effects.load(Ordering::SeqCst), 0);
client.status.store(403, Ordering::SeqCst);
let error = execute_memory_backend(
&client,
MemoryBackendOperation::ReadDocument(MemoryDocumentReadOperation {
offset: None,
limit: None,
}),
)
.await
.unwrap_err();
assert!(matches!(
error,
WorkspaceMemoryBackendError::Request(WorkspaceClientError::Request(_))
));
assert!(error.to_string().contains("authorization denied"));
assert_eq!(client.effects.load(Ordering::SeqCst), 0);
}
#[test]
fn memory_feature_owns_normal_and_staging_tool_surfaces() {
let normal = MemoryToolsFeature::new(test_client(), false);
@@ -2186,10 +2587,15 @@ permission = "write"
host: crate::subjektiv::test_connection(repository_error_client(status, code)),
operation: SubjectConsolidationOperation::DecideCandidate,
};
let output = tool
.execute(&input, ToolExecutionContext::direct())
.await
.expect("typed conflicts are model-visible retry output");
let result = tool.execute(&input, ToolExecutionContext::direct()).await;
if status == 403 {
let error =
result.expect_err("provider permission denial is not a retryable conflict");
assert!(error.to_string().contains("permission denied"));
assert!(error.to_string().contains(code));
continue;
}
let output = result.expect("typed conflicts are model-visible retry output");
let content: serde_json::Value =
serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(content["status"], "error");
@@ -305,7 +305,93 @@ fn lifecycle_task_spec(name: &'static str, description: &'static str) -> Backgro
spec
}
#[derive(Clone)]
struct MemoryExtractionAuthority {
workspace: Arc<dyn WorkspaceClient>,
subject: Option<crate::subjektiv::SubjektivHostConnection>,
}
impl MemoryExtractionAuthority {
fn authorize(&self, operation: &str) -> Result<(), HookError> {
let result = match &self.subject {
Some(host) => host
.host
.authorize_operation(&host.context, operation)
.map_err(|error| error.to_string()),
None => super::memory::authorize_workspace_memory_operation(
self.workspace.as_ref(),
operation,
)
.map_err(|error| error.to_string()),
};
result.map_err(|error| {
HookError::new(
HookErrorCategory::Dependency,
format!("Memory background operation {operation} not started: {error}"),
)
})
}
}
impl FeatureModule for MemoryExtractionAuthority {
fn descriptor(&self) -> FeatureDescriptor {
FeatureDescriptor::builtin("memory-extraction-authority", "Memory extraction authority")
.with_hook(crate::feature::HookDeclaration::new(
"supply",
crate::feature::FeatureHookPoint::PreLlmRequest,
))
.with_hook(crate::feature::HookDeclaration::new(
"access",
crate::feature::FeatureHookPoint::PreToolCall,
))
}
fn install(&self, context: &mut FeatureInstallContext<'_>) -> Result<(), FeatureInstallError> {
context.hooks().add_pre_llm_request(
"supply",
crate::hook::HookExecutionPolicy::fail_closed(),
self.clone(),
)?;
context.hooks().add_pre_tool_call("access", self.clone())
}
}
#[async_trait]
impl crate::hook::Hook<crate::hook::PreLlmRequest> for MemoryExtractionAuthority {
async fn call(
&self,
_context: &crate::hook::PreRequestContext,
) -> Result<crate::hook::HookPreRequestAction, HookError> {
self.authorize("memory.extract.supply")?;
Ok(crate::hook::HookPreRequestAction::Continue)
}
}
#[async_trait]
impl crate::hook::Hook<crate::hook::PreToolCall> for MemoryExtractionAuthority {
async fn call(
&self,
context: &crate::hook::ToolCallSummary,
) -> Result<crate::hook::HookPreToolAction, HookError> {
match context.tool_name.as_str() {
"ShowOverview" | "SearchEntries" | "ReadEntry" => {
self.authorize("memory.extract.read")?
}
"StageMemoryCandidate" => self.authorize("memory.staging.write")?,
_ => {}
}
Ok(crate::hook::HookPreToolAction::Continue)
}
}
impl MemoryLifecycleTask {
fn authority(&self) -> MemoryExtractionAuthority {
MemoryExtractionAuthority {
workspace: Arc::clone(&self.workspace_client),
subject: self.subjektiv_host.clone(),
}
}
fn pointer_domain(&self) -> &'static str {
match self.target {
ExtractionTarget::WorkspaceMemory => extract::EXTRACT_DOMAIN,
@@ -346,6 +432,7 @@ impl MemoryLifecycleTask {
} else {
audit
};
self.authority().authorize("memory.extract.read")?;
let capture = match self.capture.capture() {
Ok(capture) => capture,
Err(error) => {
@@ -556,7 +643,9 @@ impl MemoryLifecycleTask {
let _ = sender.send(()).await;
});
};
self.authority().authorize("memory.extract.supply")?;
let features = FeatureRegistryBuilder::new()
.with_module(self.authority())
.with_module(SessionExploreFeature::new(SessionExploreState::new(
view.clone(),
)))
@@ -693,6 +782,14 @@ impl MemoryLifecycleTask {
.await;
return Ok(());
}
self.authority().authorize("memory.extract.commit")?;
context.generation_fence.ensure_current()?;
if cancellation.is_cancelled() {
return Err(HookError::new(
HookErrorCategory::Cancelled,
"Memory extraction cancelled before pointer commit",
));
}
if !self
.extensions
.append_if_current(&capture.location(), self.pointer_domain(), payload)
@@ -739,24 +836,26 @@ impl FeatureBackgroundTask for MemoryLifecycleTask {
} else {
Ok(())
};
extraction?;
if !cancellation.is_cancelled() {
context.generation_fence.ensure_current()?;
match self.target {
ExtractionTarget::WorkspaceMemory if self.config.consolidation_request_enabled => {
self.request_consolidation(false).await;
self.request_consolidation(false).await?;
}
ExtractionTarget::Subjektiv => {
self.request_consolidation(true).await;
self.request_consolidation(true).await?;
}
ExtractionTarget::WorkspaceMemory => {}
}
}
extraction
Ok(())
}
}
impl MemoryLifecycleTask {
async fn request_consolidation(&self, subjektiv: bool) {
async fn request_consolidation(&self, subjektiv: bool) -> Result<(), HookError> {
self.authority().authorize("memory.consolidation.request")?;
let audit = WorkerAuditBase::new(
memory::audit::AuditWorker::MemoryConsolidation,
memory::audit::AuditTrigger::StagingBacklog,
@@ -805,8 +904,13 @@ impl MemoryLifecycleTask {
None,
)
.await;
return Err(HookError::new(
HookErrorCategory::Dependency,
format!("Memory consolidation request not completed: {error}"),
));
}
}
Ok(())
}
async fn record_preparation_failure(
@@ -1020,7 +1124,7 @@ impl WorkerAuditBase {
consolidation,
};
if self.persist_legacy_backend {
let _ = workspace_client
if let Err(error) = workspace_client
.execute_memory_backend_operation(
memory::backend::MemoryBackendOperation::AppendAudit(
memory::backend::MemoryAppendAuditOperation {
@@ -1030,7 +1134,10 @@ impl WorkerAuditBase {
},
),
)
.await;
.await
{
tracing::warn!(%error, "optional Memory audit not persisted");
}
}
if let Some(tx) = event_tx {
let _ = tx.send(Event::MemoryWorker(protocol::MemoryWorkerEvent {
@@ -1078,6 +1185,8 @@ mod tests {
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
denied: std::sync::atomic::AtomicBool,
checks: Mutex<Vec<String>>,
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
}
@@ -1098,6 +1207,22 @@ mod tests {
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
if request.path.contains("/authorization/") {
let check: serde_json::Value =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.checks
.lock()
.unwrap()
.push(check["operation"].as_str().unwrap().to_owned());
return Ok(crate::worker::WorkspaceResponse {
status: if self.denied.load(Ordering::SeqCst) {
403
} else {
200
},
body: "{}".into(),
});
}
let is_subjektiv_stage = request.path.ends_with("/subjektiv/staging");
let is_subjektiv_consolidation = request.path.ends_with("/subjektiv/consolidation");
let is_stage_candidate = request
@@ -1186,6 +1311,7 @@ mod tests {
#[derive(Clone)]
struct ScriptClient {
revoke_after_request: Option<Arc<RecordingWorkspaceClient>>,
responses: Arc<Vec<Vec<LlmEvent>>>,
calls: Arc<AtomicUsize>,
}
@@ -1193,6 +1319,7 @@ mod tests {
impl ScriptClient {
fn new(responses: Vec<Vec<LlmEvent>>) -> Self {
Self {
revoke_after_request: None,
responses: Arc::new(responses),
calls: Arc::new(AtomicUsize::new(0)),
}
@@ -1211,6 +1338,9 @@ mod tests {
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
let index = self.calls.fetch_add(1, Ordering::SeqCst);
if let Some(host) = &self.revoke_after_request {
host.denied.store(true, Ordering::SeqCst);
}
let events = self.responses.get(index).cloned().ok_or_else(|| {
ClientError::Config("memory lifecycle test client exhausted".to_string())
})?;
@@ -1529,6 +1659,72 @@ permission = "write"
);
}
#[tokio::test]
async fn denied_background_extraction_never_reads_capture_or_calls_model_or_writes() {
let client = ScriptClient::new(Vec::new());
let calls = Arc::clone(&client.calls);
let extension_writes = Arc::new(Mutex::new(Vec::new()));
let (event_tx, _) = broadcast::channel(16);
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
workspace_client.denied.store(true, Ordering::SeqCst);
let mut task = test_task(
capture(2, 250),
Box::new(client),
extension_writes.clone(),
event_tx,
workspace_client.clone(),
);
task.capture =
CommittedSessionCaptureHandle::new(|| panic!("denial must precede protected capture"));
run_background_task(task).await;
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(extension_writes.lock().unwrap().is_empty());
assert!(workspace_client.requests.lock().unwrap().is_empty());
assert_eq!(
*workspace_client.checks.lock().unwrap(),
vec!["memory.extract.read"]
);
}
#[tokio::test]
async fn background_revocation_blocks_next_model_supply_and_pointer_commit() {
let workspace_client = Arc::new(RecordingWorkspaceClient::default());
let mut client =
ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]);
client.revoke_after_request = Some(workspace_client.clone());
let calls = Arc::clone(&client.calls);
let extension_writes = Arc::new(Mutex::new(Vec::new()));
let (event_tx, _) = broadcast::channel(16);
run_background_task(test_task(
capture(2, 250),
Box::new(client),
extension_writes.clone(),
event_tx,
workspace_client.clone(),
))
.await;
assert_eq!(calls.load(Ordering::SeqCst), 1);
assert!(extension_writes.lock().unwrap().is_empty());
assert!(
!workspace_client
.requests
.lock()
.unwrap()
.iter()
.any(|request| request.path.ends_with("/consolidation"))
);
assert!(
workspace_client
.checks
.lock()
.unwrap()
.iter()
.filter(|operation| operation.as_str() == "memory.extract.supply")
.count()
>= 3
);
}
#[tokio::test]
async fn run_committed_background_task_finishes_empty_extraction_and_commits_pointer() {
let client = ScriptClient::new(vec![finish_empty_events("finish-1"), completed_events()]);
@@ -477,6 +477,7 @@ mod tests {
#[derive(Debug, Default)]
struct RecordingSubjektivClient {
deny_staging: bool,
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
}
@@ -497,6 +498,17 @@ mod tests {
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-1",
if self.deny_staging {
&[]
} else {
&["memory.staging.write"]
},
) {
return Ok(response);
}
self.requests.lock().unwrap().push(request);
Ok(crate::worker::WorkspaceResponse {
status: 200,
@@ -587,6 +599,33 @@ mod tests {
assert!(format!("{error:?}").contains("exclusively HumanInput evidence"));
}
#[tokio::test]
async fn denied_subjektiv_staging_never_mutates_or_reports_a_staged_candidate() {
let client = Arc::new(RecordingSubjektivClient {
deny_staging: true,
..Default::default()
});
let state = MemoryStagingOutputState::new_subjektiv(
SessionCapture::new("session", "segment-1", vec![Item::user_message("decision")]),
crate::subjektiv::test_connection(client.clone()),
SourceRef {
segment_id: "segment-1".into(),
range: [0, 0],
},
"run-1".into(),
);
let error = StageMemoryCandidateTool { state: state.clone() }
.execute(
r#"{"kind":"decision","claim":"claim","why_useful":"useful","entry_refs":["E00000000"]}"#,
agen::tool::ToolExecutionContext::direct(),
)
.await
.unwrap_err();
assert!(error.to_string().contains("denied"), "{error}");
assert!(state.staged().is_empty());
assert!(client.requests.lock().unwrap().is_empty());
}
#[tokio::test]
async fn subjektiv_destination_sends_committed_session_without_subject_scope() {
let client = Arc::new(RecordingSubjektivClient::default());
@@ -70,6 +70,7 @@ const INPUT_BUDGET_ERROR_MARKER: &str = "surface_editor_input_budget_exceeded";
struct InputBudgetLlmClient {
inner: Box<dyn LlmClient>,
input_token_budget: usize,
authorize_supply: Arc<dyn Fn() -> Result<(), String> + Send + Sync>,
}
impl Clone for InputBudgetLlmClient {
@@ -77,6 +78,7 @@ impl Clone for InputBudgetLlmClient {
Self {
inner: self.inner.clone_boxed(),
input_token_budget: self.input_token_budget,
authorize_supply: self.authorize_supply.clone(),
}
}
}
@@ -92,6 +94,7 @@ impl LlmClient for InputBudgetLlmClient {
request: Request,
) -> Result<Pin<Box<dyn Stream<Item = Result<LlmEvent, ClientError>> + Send>>, ClientError>
{
(self.authorize_supply)().map_err(ClientError::Config)?;
let estimated = estimated_editor_request_tokens(&request)
.map_err(|error| ClientError::Config(error.to_string()))?;
if estimated > self.input_token_budget {
@@ -383,6 +386,13 @@ impl SubjektivSurfaceLifecycleTask {
client: Box::new(InputBudgetLlmClient {
inner: self.client.clone_boxed(),
input_token_budget: generation.input_token_budget,
authorize_supply: {
let host = self.host.clone();
Arc::new(move || {
host.authorize_operation("memory.surface.supply")
.map_err(|error| error.to_string())
})
},
}),
system_prompt,
input,
@@ -659,6 +669,7 @@ mod tests {
#[derive(Debug)]
struct SurfaceWorkspaceClient {
conflict_count: usize,
allowed_operations: Mutex<Vec<&'static str>>,
input_token_budget: usize,
prepare_calls: AtomicUsize,
publish_calls: AtomicUsize,
@@ -676,6 +687,12 @@ mod tests {
fn new(conflict_count: usize) -> Self {
Self {
conflict_count,
allowed_operations: Mutex::new(vec![
"memory.surface.prepare",
"memory.surface.supply",
"memory.surface.publish",
"memory.surface.fail",
]),
input_token_budget: 12_000,
prepare_calls: AtomicUsize::new(0),
publish_calls: AtomicUsize::new(0),
@@ -729,6 +746,13 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-1",
&self.allowed_operations.lock().unwrap(),
) {
return Ok(response);
}
if request.path.ends_with("/workers/self/backend-job-result") {
assert!(
self.publish_calls.load(Ordering::SeqCst) > 0
@@ -1036,6 +1060,39 @@ permission = "write"
);
}
#[tokio::test]
async fn revoked_surface_supply_stops_the_next_request_before_provider() {
let calls = Arc::new(AtomicUsize::new(0));
let allowed = Arc::new(std::sync::atomic::AtomicBool::new(true));
let check = allowed.clone();
let client = InputBudgetLlmClient {
inner: Box::new(SurfaceEditorClient {
calls: calls.clone(),
}),
input_token_budget: 2_000,
authorize_supply: Arc::new(move || {
if check.load(Ordering::SeqCst) {
Ok(())
} else {
Err("memory.surface.supply denied".into())
}
}),
};
let _stream = client
.stream(editor_request("system policy", "protected surface"))
.await
.unwrap();
assert_eq!(calls.load(Ordering::SeqCst), 1);
allowed.store(false, Ordering::SeqCst);
let result = client
.stream(editor_request("system policy", "same protected surface"))
.await;
assert!(
matches!(result, Err(ClientError::Config(reason)) if reason.contains("memory.surface.supply denied"))
);
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn actual_editor_request_is_rejected_before_provider_when_over_budget() {
let calls = Arc::new(AtomicUsize::new(0));
@@ -1044,6 +1101,7 @@ permission = "write"
calls: calls.clone(),
}),
input_token_budget: 32,
authorize_supply: Arc::new(|| Ok(())),
};
let request = editor_request("system policy", &"x".repeat(1_000));
@@ -1063,6 +1121,7 @@ permission = "write"
calls: calls.clone(),
}),
input_token_budget: 2_000,
authorize_supply: Arc::new(|| Ok(())),
};
let _stream = client
.stream(editor_request("system policy", "small initial request"))
@@ -1079,6 +1138,53 @@ permission = "write"
assert_eq!(calls.load(Ordering::SeqCst), 1);
}
#[tokio::test]
async fn denied_surface_prepare_never_reads_materials_or_calls_provider() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace
.allowed_operations
.lock()
.unwrap()
.retain(|operation| *operation != "memory.surface.prepare");
let calls = Arc::new(AtomicUsize::new(0));
let error = test_task(workspace.clone(), calls.clone())
.generate(None, BackgroundTaskCancellation::default(), None)
.await
.unwrap_err();
assert!(error.to_string().contains("denied"));
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 0);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert!(workspace.failure_reasons.lock().unwrap().is_empty());
assert!(workspace.job_results.lock().unwrap().is_empty());
}
#[tokio::test]
async fn denied_surface_supply_stops_actual_editor_before_provider_or_publish() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(0));
workspace
.allowed_operations
.lock()
.unwrap()
.retain(|operation| *operation != "memory.surface.supply");
*workspace.failure_status.lock().unwrap() = "failed_confirmed".into();
let calls = Arc::new(AtomicUsize::new(0));
let outcome = test_task(workspace.clone(), calls.clone())
.generate(None, BackgroundTaskCancellation::default(), None)
.await
.unwrap();
assert_eq!(outcome["availability"], "failed");
assert_eq!(outcome["reason_code"], "editor_failed");
assert_eq!(workspace.prepare_calls.load(Ordering::SeqCst), 1);
assert_eq!(workspace.publish_calls.load(Ordering::SeqCst), 0);
assert_eq!(calls.load(Ordering::SeqCst), 0);
assert_eq!(
workspace.failure_reasons.lock().unwrap().as_slice(),
["editor_failed"]
);
assert!(workspace.job_results.lock().unwrap().is_empty());
}
#[tokio::test]
async fn lifecycle_rebuilds_once_after_a_store_generation_conflict() {
let workspace = Arc::new(SurfaceWorkspaceClient::new(1));
@@ -39,6 +39,7 @@ use manifest::{MergeRequestFeatureConfig, ToolPermissionAction, ToolPermissionCo
use schemars::JsonSchema;
use serde::Deserialize;
use serde_json::{Value, json};
use server_api::authorization::PermissionResource;
use sha2::{Digest, Sha256};
use std::collections::{BTreeMap, BTreeSet, HashMap};
use std::sync::{Arc, Mutex};
@@ -209,6 +210,13 @@ impl Tool for MergeRequestTool {
Kind::Readiness => {
let v: MergeRequestInput = parse(input)?;
nonempty_named("merge_request_id", &v.merge_request_id)?;
self.authorize(
"merge_request.readiness",
PermissionResource::MergeRequest {
workspace_id: ws.into(),
merge_request_id: v.merge_request_id.clone(),
},
)?;
(
WorkspaceRequestMethod::Get,
format!(
@@ -222,6 +230,13 @@ impl Tool for MergeRequestTool {
Kind::Open => {
let v: OpenMergeRequestInput = parse(input)?;
nonempty(&v.ticket)?;
self.authorize(
"merge_request.open",
PermissionResource::Ticket {
workspace_id: ws.into(),
ticket_id: v.ticket.clone(),
},
)?;
(
WorkspaceRequestMethod::Post,
format!(
@@ -236,6 +251,13 @@ impl Tool for MergeRequestTool {
Kind::Complete => {
let v: CompleteMergeRequestInput = parse(input)?;
nonempty_named("merge_request_id", &v.merge_request_id)?;
self.authorize(
"merge_request.complete",
PermissionResource::MergeRequest {
workspace_id: ws.into(),
merge_request_id: v.merge_request_id.clone(),
},
)?;
(
WorkspaceRequestMethod::Post,
format!(
@@ -254,6 +276,13 @@ impl Tool for MergeRequestTool {
"Review submit requires injected Reviewer capability".into(),
)
})?;
self.authorize(
"merge_request.review",
PermissionResource::MergeRequest {
workspace_id: ws.into(),
merge_request_id: ctx.merge_request_id.clone(),
},
)?;
(
WorkspaceRequestMethod::Post,
format!(
@@ -286,6 +315,14 @@ impl Tool for MergeRequestTool {
}
impl MergeRequestTool {
fn authorize(&self, operation: &str, resource: PermissionResource) -> Result<(), ToolError> {
self.client.authorize_operation(operation, &resource)
.map_err(|error| {
let denied = matches!(&error, crate::worker::WorkspaceClientError::Request(message)
if message.contains("HTTP status 403") || message.contains("HTTP status 401"));
ToolError::ExecutionFailed(format!("Merge Request operation authorization failed before dispatch: {operation}; resource={resource:?}; denied={denied}"))
})
}
fn show_merge_request(
&self,
workspace_id: &str,
@@ -293,6 +330,13 @@ impl MergeRequestTool {
after: Option<u64>,
limit: Option<usize>,
) -> Result<ToolOutput, ToolError> {
self.authorize(
"merge_request.read",
PermissionResource::MergeRequest {
workspace_id: workspace_id.into(),
merge_request_id: merge_request_id.into(),
},
)?;
let merge_request_id = encode_path_segment(merge_request_id);
let mut path = format!("/api/w/{workspace_id}/merge-requests/{merge_request_id}");
let mut query = Vec::new();
@@ -874,6 +918,19 @@ fn map_merge_request_tool_error(error: ToolError, _mutating: bool) -> WipOperati
ProtocolErrorCode::InvalidArguments,
format!("{code}: {message}"),
),
ToolError::ExecutionFailed(message)
if message
.starts_with("Merge Request operation authorization failed before dispatch:") =>
{
merge_request_protocol_failure(
if message.ends_with("denied=true") {
ProtocolErrorCode::PermissionDenied
} else {
ProtocolErrorCode::Internal
},
message,
)
}
ToolError::ExecutionFailed(message) => match merge_request_http_status(&message) {
Some(401 | 403) => {
merge_request_protocol_failure(ProtocolErrorCode::PermissionDenied, message)
@@ -1126,6 +1183,8 @@ mod tests {
#[derive(Debug)]
struct RecordingWorkspaceClient {
auth_denied: std::sync::atomic::AtomicBool,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
responses: Mutex<VecDeque<WorkspaceResponse>>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
@@ -1133,6 +1192,8 @@ mod tests {
impl RecordingWorkspaceClient {
fn new(responses: Vec<WorkspaceResponse>) -> Self {
Self {
auth_denied: std::sync::atomic::AtomicBool::new(false),
authorizations: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into()),
requests: Mutex::new(Vec::new()),
}
@@ -1156,6 +1217,21 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.authorizations.lock().unwrap().push(input.clone());
if self.auth_denied.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(WorkspaceResponse {
status: 403,
body: "{}".into(),
});
}
return Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
self.requests.lock().expect("request lock").push(request);
self.responses
.lock()
@@ -1751,4 +1827,131 @@ mod tests {
}
}
}
#[tokio::test]
async fn denied_merge_request_operations_do_not_reach_provider() {
for (kind, input, operation) in [
(Kind::Show, json!({"merge_request_id":"MR-1"}), "read"),
(
Kind::Readiness,
json!({"merge_request_id":"MR-1"}),
"readiness",
),
(
Kind::Open,
json!({"ticket":"T-1", "repository_key":"repo", "selector_from":"work", "selector_to":"develop"}),
"open",
),
(
Kind::Complete,
json!({"merge_request_id":"MR-1", "operation_id":"complete-1", "approval_event_id":"approved", "target_ref_before":"before", "target_ref_after":"after", "strategy":"fast_forward", "resolution":"none"}),
"complete",
),
(Kind::Review, json!({"decision":"approve"}), "review"),
] {
let client = Arc::new(RecordingWorkspaceClient::new(vec![]));
client
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
let bound: Arc<dyn WorkspaceClient> = if kind == Kind::Review {
Arc::new(crate::worker::ReviewerChildWorkspaceClient::new(
client.clone(),
crate::worker::ReviewerContext {
ticket_id: "T-1".into(),
merge_request_id: "MR-1".into(),
},
"secret".into(),
))
} else {
client.clone()
};
let tool = MergeRequestTool {
client: bound,
kind,
};
let error = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(
error.to_string().contains("denied=true"),
"{operation}: {error}"
);
assert!(matches!(
map_merge_request_tool_error(error, kind.mutating()),
WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
})
));
assert!(client.requests.lock().unwrap().is_empty());
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 1);
assert_eq!(auth[0].operation, format!("merge_request.{operation}"));
assert_eq!(
auth[0].resource,
if kind == Kind::Open {
PermissionResource::Ticket {
workspace_id: "ws".into(),
ticket_id: "T-1".into(),
}
} else {
PermissionResource::MergeRequest {
workspace_id: "ws".into(),
merge_request_id: "MR-1".into(),
}
}
);
}
}
#[tokio::test]
async fn native_merge_request_rechecks_authority_after_successful_tool_read() {
let client = Arc::new(RecordingWorkspaceClient::new(vec![response(
json!({"merge_request_id":"MR-1"}),
)]));
MergeRequestTool {
client: client.clone(),
kind: Kind::Show,
}
.execute(
r#"{"merge_request_id":"MR-1"}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
client
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
let native = native_merge_request_tools(
client.clone(),
MergeRequestFeatureConfig {
show: true,
..Default::default()
},
)
.unwrap()
.into_iter()
.find(|tool| tool.kind == Kind::Show)
.unwrap();
let result = execute_native_merge_request_tool(
&native,
&None,
json!({"merge_request_id":"MR-1"}),
Some("MR-1"),
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "test".into(),
},
)
.await;
assert!(matches!(
result,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert_eq!(client.requests.lock().unwrap().len(), 1);
assert_eq!(client.authorizations.lock().unwrap().len(), 2);
}
}
+230 -21
View File
@@ -40,6 +40,7 @@ use crate::wip::{
WipOperationOutput, WipProjection, WipProjectionKind, json_to_wip, wip_to_json,
};
use crate::worker::{WorkspaceClient, WorkspaceRequest, WorkspaceRequestMethod};
use server_api::authorization::PermissionResource;
use super::resource_projection::{project_objective_detail, project_objective_query};
@@ -53,10 +54,55 @@ impl WorkspaceHttpObjectiveBackend {
Self { client }
}
fn authorize(
&self,
operation: &str,
resource: PermissionResource,
) -> Result<(), WorkspaceObjectiveBackendError> {
self.client
.authorize_operation(operation, &resource)
.map_err(|error| {
let denied = matches!(&error, crate::worker::WorkspaceClientError::Request(message)
if message.contains("HTTP status 403") || message.contains("HTTP status 401"));
WorkspaceObjectiveBackendError::Authorization {
operation: operation.into(),
resource,
denied,
}
})?;
Ok(())
}
fn workspace_id(&self) -> Result<String, WorkspaceObjectiveBackendError> {
self.client
.workspace_id()
.filter(|id| !id.is_empty())
.map(str::to_owned)
.ok_or_else(|| {
crate::worker::WorkspaceClientError::Unavailable(
"Objective operation requires Workspace identity".into(),
)
.into()
})
}
fn objective_resource(
&self,
id: &str,
) -> Result<PermissionResource, WorkspaceObjectiveBackendError> {
Ok(PermissionResource::Objective {
workspace_id: self.workspace_id()?,
objective_id: id.into(),
})
}
async fn query_value(
&self,
input: &QueryObjectiveInput,
) -> Result<serde_json::Value, WorkspaceObjectiveBackendError> {
self.authorize(
"objective.query",
PermissionResource::Workspace {
workspace_id: self.workspace_id()?,
},
)?;
let url = format!(
"/api/w/{}/objectives/query",
self.client.workspace_id().unwrap_or_default()
@@ -89,6 +135,7 @@ impl WorkspaceHttpObjectiveBackend {
event_cursor: Option<String>,
) -> Result<serde_json::Value, WorkspaceObjectiveBackendError> {
let id = validate_backend_id(id)?;
self.authorize("objective.read", self.objective_resource(id)?)?;
let url = format!("{}/show", self.objective_url(id));
let response = send_json::<ObjectiveShowRequest, serde_json::Value>(
self.client.as_ref(),
@@ -126,6 +173,12 @@ impl WorkspaceHttpObjectiveBackend {
&self,
input: &ObjectiveCreateInput,
) -> Result<serde_json::Value, WorkspaceObjectiveBackendError> {
self.authorize(
"objective.create",
PermissionResource::Workspace {
workspace_id: self.workspace_id()?,
},
)?;
if input.title.trim().is_empty() {
return Err(WorkspaceObjectiveBackendError::InvalidArgument(
"ObjectiveCreate requires non-empty title".to_string(),
@@ -157,6 +210,7 @@ impl WorkspaceHttpObjectiveBackend {
input: &ObjectiveEditRequest,
) -> Result<serde_json::Value, WorkspaceObjectiveBackendError> {
let id = validate_backend_id(id)?;
self.authorize("objective.edit", self.objective_resource(id)?)?;
if input.title.is_none() && input.old_string.is_none() && input.new_string.is_none() {
return Err(WorkspaceObjectiveBackendError::InvalidArgument(
"ObjectiveEdit requires title or old_string/new_string".to_string(),
@@ -191,6 +245,7 @@ impl WorkspaceHttpObjectiveBackend {
state: &str,
) -> Result<serde_json::Value, WorkspaceObjectiveBackendError> {
let id = validate_backend_id(id)?;
self.authorize("objective.set_state", self.objective_resource(id)?)?;
if state.trim().is_empty() {
return Err(WorkspaceObjectiveBackendError::InvalidArgument(
"ObjectiveSetState requires non-empty state".to_string(),
@@ -229,6 +284,7 @@ impl WorkspaceHttpObjectiveBackend {
let id = validate_backend_id(id)?;
let ticket_id = validate_backend_id(ticket_id)?;
let ticket_resource_key = self.ticket_resource_key_raw(ticket_id).await?;
self.authorize("objective.link_ticket", self.objective_resource(id)?)?;
let response = send_json::<ObjectiveLinkTicketRequest, serde_json::Value>(
self.client.as_ref(),
reqwest::Method::POST,
@@ -263,6 +319,7 @@ impl WorkspaceHttpObjectiveBackend {
let id = validate_backend_id(id)?;
let ticket_id = validate_backend_id(ticket_id)?;
let ticket_resource_key = self.ticket_resource_key_raw(ticket_id).await?;
self.authorize("objective.unlink_ticket", self.objective_resource(id)?)?;
let response = delete_json::<serde_json::Value>(
self.client.as_ref(),
&format!(
@@ -296,7 +353,14 @@ impl WorkspaceHttpObjectiveBackend {
&self,
ticket_reference: &str,
) -> Result<String, WorkspaceObjectiveBackendError> {
let workspace_id = self.client.workspace_id().unwrap_or_default();
let workspace_id = self.workspace_id()?;
self.authorize(
"ticket.read",
PermissionResource::Ticket {
workspace_id: workspace_id.clone(),
ticket_id: ticket_reference.into(),
},
)?;
let response: serde_json::Value =
decode_response(self.client.execute(WorkspaceRequest::get(format!(
"/api/w/{workspace_id}/tickets/{}",
@@ -330,6 +394,14 @@ impl WorkspaceHttpObjectiveBackend {
#[derive(Debug, thiserror::Error)]
pub enum WorkspaceObjectiveBackendError {
#[error(
"Objective operation authorization failed before dispatch: {operation}; resource={resource:?}; denied={denied}"
)]
Authorization {
operation: String,
resource: PermissionResource,
denied: bool,
},
#[error("invalid Objective operation: {0}")]
InvalidArgument(String),
#[error("workspace objective backend request failed: {0}")]
@@ -880,6 +952,20 @@ fn authorize_native(
fn map_backend_error(error: WorkspaceObjectiveBackendError) -> WipOperationError {
match error {
WorkspaceObjectiveBackendError::Authorization {
operation,
resource: _,
denied,
} => protocol_failure(
if denied {
ProtocolErrorCode::PermissionDenied
} else {
ProtocolErrorCode::Internal
},
format!(
"Objective operation authorization failed before dispatch: {operation}; denied={denied}"
),
),
WorkspaceObjectiveBackendError::InvalidArgument(message) => {
protocol_failure(ProtocolErrorCode::InvalidArguments, message)
}
@@ -1458,8 +1544,49 @@ mod tests {
thread,
};
fn accept_effect(listener: &TcpListener) -> (std::net::TcpStream, String) {
for _ in 0..4 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut bytes = Vec::new();
let request = loop {
let mut buffer = [0; 8192];
let len = stream.read(&mut buffer).unwrap();
assert!(len > 0 && bytes.len() + len <= 32 * 1024);
bytes.extend_from_slice(&buffer[..len]);
let text = String::from_utf8_lossy(&bytes);
if let Some((headers, body)) = text.split_once("\r\n\r\n") {
let length = headers
.lines()
.find_map(|line| {
line.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|n| n.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
if body.len() >= length {
break text.into_owned();
}
}
};
if request.starts_with("POST /api/w/workspace/authorization/check ") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.split_once("\r\n\r\n").unwrap().1).unwrap();
let body = json!({"scope":input.resource}).to_string();
write!(stream, "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: {}\r\n\r\n{}", body.len(), body).unwrap();
} else {
return (stream, request);
}
}
panic!("expected protected provider operation after authorization");
}
#[derive(Debug)]
struct MockWorkspaceClient {
auth_denied: std::sync::atomic::AtomicBool,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
responses: Mutex<VecDeque<crate::worker::WorkspaceResponse>>,
requests: Mutex<Vec<WorkspaceRequest>>,
}
@@ -1467,6 +1594,8 @@ mod tests {
impl MockWorkspaceClient {
fn new(responses: impl IntoIterator<Item = crate::worker::WorkspaceResponse>) -> Self {
Self {
auth_denied: std::sync::atomic::AtomicBool::new(false),
authorizations: Mutex::new(Vec::new()),
responses: Mutex::new(responses.into_iter().collect()),
requests: Mutex::new(Vec::new()),
}
@@ -1497,6 +1626,21 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
self.authorizations.lock().unwrap().push(input.clone());
if self.auth_denied.load(std::sync::atomic::Ordering::SeqCst) {
return Ok(crate::worker::WorkspaceResponse {
status: 403,
body: "{}".into(),
});
}
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
self.requests
.lock()
.unwrap_or_else(|error| error.into_inner())
@@ -1584,10 +1728,7 @@ mod tests {
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 stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
let (mut stream, request) = accept_effect(&listener);
assert!(
request.starts_with("POST /api/w/workspace/objectives/00001INTERNAL/show HTTP/1.1")
);
@@ -1639,10 +1780,7 @@ mod tests {
let base_url = format!("http://{}", listener.local_addr().unwrap());
let server = thread::spawn(move || {
for mutation in ["POST", "DELETE"] {
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
let (mut stream, request) = accept_effect(&listener);
assert!(request.starts_with("GET /api/w/workspace/tickets/00001INTERNAL HTTP/1.1"));
let response_body = serde_json::json!({"resource_key": "T-7"}).to_string();
write!(
@@ -1653,10 +1791,7 @@ mod tests {
)
.unwrap();
let (mut stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
let (mut stream, request) = accept_effect(&listener);
assert!(request.starts_with(&format!(
"{mutation} /api/w/workspace/objectives/O-3/ticket-links"
)));
@@ -2077,10 +2212,7 @@ mod tests {
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 stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
let (mut stream, request) = accept_effect(&listener);
assert!(request.starts_with("POST /api/w/workspace/objectives/query HTTP/1.1"));
let response_body = serde_json::json!({
"items": [{
@@ -2144,10 +2276,7 @@ mod tests {
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 stream, _) = listener.accept().unwrap();
let mut buffer = [0_u8; 8192];
let len = stream.read(&mut buffer).unwrap();
let request = String::from_utf8_lossy(&buffer[..len]);
let (mut stream, request) = accept_effect(&listener);
assert!(request.starts_with("PATCH /api/w/workspace/objectives/O-3 HTTP/1.1"));
let body = request.split("\r\n\r\n").nth(1).unwrap_or_default();
assert!(body.contains("Changed"));
@@ -2221,4 +2350,84 @@ mod tests {
});
assert!(objective_output("created".to_string(), &response).is_err());
}
#[tokio::test]
async fn denied_objective_tools_and_wip_do_not_reach_provider() {
for (operation, input) in [
(ObjectiveOperation::List, json!({})),
(ObjectiveOperation::Show, json!({"id":"O-3"})),
(ObjectiveOperation::Create, json!({"title":"new"})),
(ObjectiveOperation::Edit, json!({"id":"O-3", "title":"new"})),
(
ObjectiveOperation::SetState,
json!({"id":"O-3", "state":"active"}),
),
(
ObjectiveOperation::LinkTicket,
json!({"id":"O-3", "ticket_id":"T-7"}),
),
(
ObjectiveOperation::UnlinkTicket,
json!({"id":"O-3", "ticket_id":"T-7"}),
),
] {
let client = Arc::new(MockWorkspaceClient::new([]));
client
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
let tool = WorkspaceHttpObjectiveTool {
backend: WorkspaceHttpObjectiveBackend::new(client.clone()),
operation,
};
let error = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains("denied=true"));
assert!(client.requests().is_empty());
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 1);
assert!(
matches!(&auth[0].resource, PermissionResource::Workspace { workspace_id } | PermissionResource::Objective { workspace_id, .. } | PermissionResource::Ticket { workspace_id, .. } if workspace_id == "workspace")
);
}
let client = Arc::new(MockWorkspaceClient::new([objective_response("one")]));
let backend = WorkspaceHttpObjectiveBackend::new(client.clone());
backend.read_value("O-3", None, None).await.unwrap();
client
.auth_denied
.store(true, std::sync::atomic::Ordering::SeqCst);
let item = ObjectiveItemWipHandler {
backend,
permissions: None,
objective_reference: "O-3".into(),
};
let result = item
.call(
"read",
&BTreeMap::new(),
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "test".into(),
},
)
.await;
assert!(matches!(
result,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert_eq!(client.requests().len(), 1);
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 2);
assert_eq!(auth[1].operation, "objective.read");
assert_eq!(
auth[1].resource,
PermissionResource::Objective {
workspace_id: "workspace".into(),
objective_id: "O-3".into()
}
);
}
}
@@ -965,6 +965,13 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-test",
&["memory.receipt.read"],
) {
return Ok(response);
}
let request: SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let SubjektivMemoryBackendOperation::ReceiptStatus(input) = request.operation else {
@@ -1004,8 +1011,15 @@ mod tests {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-test",
&["memory.query"],
) {
return Ok(response);
}
Ok(WorkspaceResponse {
status: 409,
body: serde_json::to_string(&server_api::RepositoryApiError::new(
@@ -1043,6 +1057,13 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-test",
&["memory.read"],
) {
return Ok(response);
}
let request: SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(matches!(
@@ -1059,6 +1080,7 @@ mod tests {
#[derive(Debug, Default)]
struct RecordingWorkspaceClient {
deny_memory: bool,
requests: Mutex<Vec<WorkspaceRequest>>,
staged: Mutex<HashSet<String>>,
fail_stages: AtomicUsize,
@@ -1081,6 +1103,17 @@ mod tests {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if let Some(response) = crate::subjektiv::test_subject_authorization_response(
&request,
"workspace-test",
if self.deny_memory {
&[]
} else {
&["memory.receipt.read", "memory.staging.write"]
},
) {
return Ok(response);
}
self.requests.lock().unwrap().push(request.clone());
let request: SubjektivMemoryBackendRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
@@ -1342,6 +1375,38 @@ mod tests {
}
}
#[tokio::test]
async fn denied_memory_reads_never_reach_backend_or_return_protected_content() {
let client = Arc::new(RecordingWorkspaceClient {
deny_memory: true,
..Default::default()
});
let state = SubjektivMemoryState {
host: crate::subjektiv::test_connection(client.clone()),
capture: feature().state.capture,
};
for (operation, input) in [
(ReadOperation::Query, "{}"),
(ReadOperation::Read, r#"{"memory_id":"memory-1"}"#),
] {
let error = SubjektivReadTool {
state: state.clone(),
operation,
}
.execute(input, ToolExecutionContext::new("call-denied", "batch", 0))
.await
.unwrap_err();
assert!(error.to_string().contains("denied"), "{error}");
}
assert!(
state
.receipt_status("explicit:session-1:call-denied")
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
assert!(client.staged.lock().unwrap().is_empty());
}
#[test]
fn receipt_identity_is_stable_and_rejects_invalid_values() {
assert_eq!(
File diff suppressed because it is too large Load Diff
@@ -12,6 +12,8 @@
//! not mutation authority. Delivery/start cursors are independent of handling history; Runtime
//! acceptance, Worker liveness, and the absence of diagnostics never attest model processing.
#[cfg(test)]
mod authorization_tests;
#[cfg(test)]
mod tests;
use super::*;
use crate::worker::WorkspaceServerOperation;
@@ -352,6 +354,33 @@ impl Tool for PublicAssignmentTool {
}
}
};
let ticket_id = match &operation {
WorkspaceServerOperation::TicketAssignmentSet { ticket_id, .. }
| WorkspaceServerOperation::TicketAssignmentClear { ticket_id, .. }
| WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, .. }
| WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, .. }
| WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, .. }
| WorkspaceServerOperation::TicketImplementationResult { ticket_id, .. }
| WorkspaceServerOperation::TicketImplementationResultGet { ticket_id, .. } => {
ticket_id
}
_ => unreachable!("public assignment operation"),
};
assignment_tracking::authorize_ticket_target(
self.client.as_ref(),
match self.kind {
PublicAssignmentKind::Assign => "ticket.assignment.assign",
PublicAssignmentKind::Clear => "ticket.assignment.clear",
PublicAssignmentKind::Assignments => "ticket.assignment.query",
PublicAssignmentKind::Diagnostics => "ticket.assignment.diagnostics",
PublicAssignmentKind::Answer => "ticket.assignment.answer",
PublicAssignmentKind::ImplementationResult => "ticket.implementation_result.write",
PublicAssignmentKind::ImplementationResultQuery => {
"ticket.implementation_result.read"
}
},
ticket_id,
)?;
let response = self
.client
.execute_server_operation(operation)
@@ -0,0 +1,102 @@
use super::tests::cases;
use super::*;
use assignment_tracking::authorization_tests::{AuthorizedClient, ticket};
#[tokio::test]
async fn public_operations_require_exact_ticket_grants_on_tool_and_wip() {
for (kind, input, response) in cases() {
let operation = match kind {
PublicAssignmentKind::Assign => "ticket.assignment.assign",
PublicAssignmentKind::Clear => "ticket.assignment.clear",
PublicAssignmentKind::Assignments => "ticket.assignment.query",
PublicAssignmentKind::Diagnostics => "ticket.assignment.diagnostics",
PublicAssignmentKind::Answer => "ticket.assignment.answer",
PublicAssignmentKind::ImplementationResult => "ticket.implementation_result.write",
PublicAssignmentKind::ImplementationResultQuery => "ticket.implementation_result.read",
};
let client = AuthorizedClient::new(response.clone());
let (_, tool) = definition(client.clone(), kind)();
let error = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(
error.to_string().contains("HTTP status 403"),
"{}: {error}",
kind.name()
);
assert!(client.requests.lock().unwrap().is_empty());
client.grant(operation, ticket("T-743"));
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let actual: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
for (key, value) in response.as_object().unwrap() {
assert_eq!(&actual[key], value);
}
let mut other = input.clone();
other["ticket"] = json!("T-B");
assert!(
tool.execute(&other.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-743".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = input
.as_object()
.unwrap()
.iter()
.filter(|(key, _)| key.as_str() != "ticket")
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
let projection = native_ticket_operation(kind.name()).unwrap();
handler
.call(
projection.operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP {} rejected allowed operation", kind.name()));
assert_eq!(client.requests.lock().unwrap().len(), 2);
client.grants.lock().unwrap().clear();
assert!(matches!(
handler
.call(
projection.operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert_eq!(client.requests.lock().unwrap().len(), 2);
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 5);
assert!(auth.iter().all(|request| request.operation == operation));
assert_eq!(auth[0].resource, ticket("T-743"));
assert_eq!(auth[2].resource, ticket("T-B"));
}
}
@@ -8,6 +8,13 @@ struct Transport {
available: bool,
}
impl WorkspaceClient for Transport {
fn authorize_operation(
&self,
_: &str,
_: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
Ok(())
}
fn workspace_id(&self) -> Option<&str> {
self.available.then_some("workspace")
}
@@ -35,7 +42,7 @@ fn transport(status: u16, body: Value) -> Arc<Transport> {
}),
})
}
fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
pub(super) fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> {
vec![
(
PublicAssignmentKind::Assign,
@@ -107,6 +107,30 @@ fn encode_segment(value: &str) -> String {
.collect()
}
pub(super) fn authorize_ticket_target(
client: &dyn WorkspaceClient,
operation: &str,
ticket_id: &str,
) -> Result<(), ToolError> {
let workspace_id = client
.workspace_id()
.filter(|id| !id.trim().is_empty())
.ok_or_else(|| {
ToolError::InvalidArgument(
"Ticket authorization requires Backend Workspace identity".into(),
)
})?;
client
.authorize_operation(
operation,
&server_api::authorization::PermissionResource::Ticket {
workspace_id: workspace_id.into(),
ticket_id: ticket_id.into(),
},
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))
}
#[derive(Clone)]
struct TrackingTool {
client: Arc<dyn WorkspaceClient>,
@@ -128,7 +152,7 @@ impl Tool for TrackingTool {
})?;
let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace));
let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string());
let (method, path, body) = match self.kind {
let (method, path, body, ticket_id) = match self.kind {
TrackingToolKind::Query => {
let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?;
required("ticket", &input.ticket)?;
@@ -143,7 +167,7 @@ impl Tool for TrackingTool {
encode_segment(&assignment_id)
));
}
(WorkspaceRequestMethod::Get, path, None)
(WorkspaceRequestMethod::Get, path, None, input.ticket)
}
TrackingToolKind::History => {
let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?;
@@ -164,7 +188,7 @@ impl Tool for TrackingTool {
required("after_event_id", &after)?;
path.push_str(&format!("&after_event_id={}", encode_segment(&after)));
}
(WorkspaceRequestMethod::Get, path, None)
(WorkspaceRequestMethod::Get, path, None, input.ticket)
}
TrackingToolKind::Update => {
let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?;
@@ -186,6 +210,7 @@ impl Tool for TrackingTool {
encode_segment(&input.ticket)
),
Some(body.to_string()),
input.ticket,
)
}
};
@@ -193,6 +218,15 @@ impl Tool for TrackingTool {
Some(body) => WorkspaceRequest::json(method, path, body),
None => WorkspaceRequest::get(path),
};
authorize_ticket_target(
self.client.as_ref(),
match self.kind {
TrackingToolKind::Query => "ticket.assignment.query",
TrackingToolKind::History => "ticket.assignment.history",
TrackingToolKind::Update => "ticket.assignment.update",
},
&ticket_id,
)?;
let response = self
.client
.execute(request)
@@ -239,6 +273,9 @@ pub(super) fn definition(
})
}
#[cfg(test)]
pub(super) mod authorization_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -251,6 +288,13 @@ mod tests {
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for Transport {
fn authorize_operation(
&self,
_: &str,
_: &server_api::authorization::PermissionResource,
) -> Result<(), WorkspaceClientError> {
Ok(())
}
fn workspace_id(&self) -> Option<&str> {
self.workspace.as_deref()
}
@@ -0,0 +1,194 @@
use super::*;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
use server_api::authorization::{
OperationAuthorizationRequest, PermissionResource, ScopedPermission,
};
// Component connection: real Tools/native WIP and WorkspaceClient authorization
// forwarding; the Host HTTP boundary evaluates exact operation/resource grants.
#[derive(Debug)]
pub(in crate::feature::builtin::ticket) struct AuthorizedClient {
pub grants: Mutex<Vec<ScopedPermission>>,
pub authorizations: Mutex<Vec<OperationAuthorizationRequest>>,
pub requests: Mutex<Vec<WorkspaceRequest>>,
response: Value,
}
impl AuthorizedClient {
pub fn new(response: Value) -> Arc<Self> {
Arc::new(Self {
grants: Mutex::new(vec![]),
authorizations: Mutex::new(vec![]),
requests: Mutex::new(vec![]),
response,
})
}
pub fn grant(&self, operation: &str, resource: PermissionResource) {
self.grants.lock().unwrap().push(ScopedPermission {
operation: operation.into(),
resource,
});
}
}
impl WorkspaceClient for AuthorizedClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"authorized-assignment-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path == "/api/w/workspace/authorization/check" {
let auth: OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let requested = ScopedPermission {
operation: auth.operation.clone(),
resource: auth.resource.clone(),
};
let allowed = self
.grants
.lock()
.unwrap()
.iter()
.any(|grant| grant.covers(&requested));
self.authorizations.lock().unwrap().push(auth);
return Ok(WorkspaceResponse {
status: if allowed { 200 } else { 403 },
body: if allowed {
json!({"scope": requested.resource})
} else {
json!({"message":"not granted"})
}
.to_string(),
});
}
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: 200,
body: self.response.to_string(),
})
}
}
pub(in crate::feature::builtin::ticket) fn ticket(id: &str) -> PermissionResource {
PermissionResource::Ticket {
workspace_id: "workspace".into(),
ticket_id: id.into(),
}
}
#[tokio::test]
async fn tracking_operations_authorize_exact_ticket_before_dispatch_and_recheck_revocation() {
for (kind, operation, input) in [
(
TrackingToolKind::Query,
"ticket.assignment.query",
json!({"ticket":"T-A"}),
),
(
TrackingToolKind::History,
"ticket.assignment.history",
json!({"ticket":"T-A","assignment_id":"a"}),
),
(
TrackingToolKind::Update,
"ticket.assignment.update",
json!({"ticket":"T-A","assignment_id":"a","expected_last_event_id":"before","operation_key":"report","tracking":{"status":"done","waiting_reason":null,"progress":null,"next_action":null,"result":"Returned options"}}),
),
] {
let client = AuthorizedClient::new(json!({"assignment_id":"a","last_event_id":"after"}));
let (_, tool) = definition(client.clone(), kind)();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("HTTP status 403")
);
assert!(client.requests.lock().unwrap().is_empty());
client.grant(operation, ticket("T-A"));
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap()["last_event_id"],
"after"
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let mut other = input.clone();
other["ticket"] = json!("T-B");
assert!(
tool.execute(&other.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
client.grants.lock().unwrap().clear();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 4);
assert!(auth.iter().all(|request| request.operation == operation));
assert_eq!(auth[0].resource, ticket("T-A"));
assert_eq!(auth[2].resource, ticket("T-B"));
drop(auth);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let handler = TicketItemWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Item)
.collect(),
),
permissions: None,
ticket_reference: "T-A".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = input
.as_object()
.unwrap()
.iter()
.filter(|(key, _)| key.as_str() != "ticket")
.map(|(key, value)| (key.clone(), json_to_wip(value).unwrap()))
.collect();
let projection = native_ticket_operation(kind.name()).unwrap();
assert!(matches!(
handler
.call(
projection.operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert_eq!(client.requests.lock().unwrap().len(), 1);
client.grant(operation, ticket("T-A"));
handler
.call(
projection.operation,
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP rejected allowed tracking operation"));
assert_eq!(client.requests.lock().unwrap().len(), 2);
}
}
@@ -13,7 +13,8 @@ struct DiagnosticsTool {
#[async_trait]
impl Tool for DiagnosticsTool {
async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> {
self.client
let workspace_id = self
.client
.workspace_id()
.filter(|s| !s.trim().is_empty())
.ok_or_else(|| {
@@ -49,6 +50,58 @@ impl Tool for DiagnosticsTool {
));
}
}
use server_api::authorization::PermissionResource;
let mut resources = Vec::new();
if let Some(job_id) = &query.job_id {
resources.push(PermissionResource::Job {
workspace_id: workspace_id.into(),
job_id: job_id.clone(),
});
}
if let Some(ticket_id) = &query.ticket {
resources.push(PermissionResource::Ticket {
workspace_id: workspace_id.into(),
ticket_id: ticket_id.clone(),
});
}
if let Some(merge_request_id) = &query.merge_request_id {
resources.push(PermissionResource::MergeRequest {
workspace_id: workspace_id.into(),
merge_request_id: merge_request_id.clone(),
});
}
// source_checks probes origins independently of the page's other filters.
// Opaque (including hashed and legacy Job) identities must resolve from
// Host metadata, never from a caller-supplied Job filter.
let mut bound_source = None;
if let Some(event) = &query.source_event_id {
// Ticket ordinals are scoped by source_checks to this Ticket. All
// global identities use persisted ownership, including legacy Job
// IDs whose spelling happens to resemble an MR identity.
if !(event.starts_with("ticket-event:") && query.ticket.is_some()) {
bound_source = Some(event.clone());
}
}
if resources.is_empty() && bound_source.is_none() {
resources.push(PermissionResource::Workspace {
workspace_id: workspace_id.into(),
});
}
for resource in resources {
self.client
.authorize_operation("notification.diagnostics.read", &resource)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
}
if let Some(source_event_id) = bound_source {
self.client
.authorize_bound_operation(
"notification.diagnostics.read",
server_api::authorization::BoundAuthorizationTarget::NotificationSource {
source_event_id,
},
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
}
let response = self
.client
.execute_server_operation(WorkspaceServerOperation::NotificationDiagnostics { query })
@@ -83,6 +136,310 @@ impl Tool for DiagnosticsTool {
})
}
}
#[cfg(test)]
mod authorization_tests {
use super::*;
use assignment_tracking::authorization_tests::{AuthorizedClient, ticket};
use server_api::authorization::PermissionResource;
fn mr(id: &str) -> PermissionResource {
PermissionResource::MergeRequest {
workspace_id: "workspace".into(),
merge_request_id: id.into(),
}
}
fn job(id: &str) -> PermissionResource {
PermissionResource::Job {
workspace_id: "workspace".into(),
job_id: id.into(),
}
}
fn workspace() -> PermissionResource {
PermissionResource::Workspace {
workspace_id: "workspace".into(),
}
}
#[tokio::test]
async fn notification_filters_authorize_all_independent_origins_before_read() {
for (input, resources) in [
(json!({"job_id":"Job-A"}), vec![job("Job-A")]),
(
json!({"ticket":"T-A","merge_request_id":"MR-A","job_id":"Job-A"}),
vec![job("Job-A"), ticket("T-A"), mr("MR-A")],
),
(json!({"ticket":"T-A"}), vec![ticket("T-A")]),
(json!({"merge_request_id":"MR-A"}), vec![mr("MR-A")]),
(
json!({"ticket":"T-A","merge_request_id":"MR-A"}),
vec![ticket("T-A"), mr("MR-A")],
),
(
json!({"ticket":"T-A","source_event_id":"ticket-event:8"}),
vec![ticket("T-A")],
),
(
json!({"source_id":"notification-source:8"}),
vec![workspace()],
),
(json!({}), vec![workspace()]),
] {
let client = AuthorizedClient::new(
json!({"items":[],"next_cursor":"notification-source:8","source_checks":[]}),
);
let (_, tool) = definition(client.clone())();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("HTTP status 403")
);
assert!(client.requests.lock().unwrap().is_empty());
for resource in &resources {
client.grant("notification.diagnostics.read", resource.clone());
}
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap()["next_cursor"],
"notification-source:8"
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let auth = client.authorizations.lock().unwrap();
assert_eq!(
auth[1..]
.iter()
.map(|request| request.resource.clone())
.collect::<Vec<_>>(),
resources
);
drop(auth);
client.grants.lock().unwrap().pop();
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(
client.requests.lock().unwrap().len(),
1,
"every filter needs authority"
);
}
}
#[tokio::test]
async fn notification_ticket_grant_rejects_other_ticket_and_wip_rechecks_revocation() {
let client =
AuthorizedClient::new(json!({"items":[],"next_cursor":null,"source_checks":[]}));
client.grant("notification.diagnostics.read", ticket("T-A"));
let (_, tool) = definition(client.clone())();
assert!(
tool.execute(r#"{"ticket":"T-B"}"#, ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap();
let handler = TicketCollectionWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Collection)
.collect(),
),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = BTreeMap::from([("ticket".into(), WipValue::String("T-A".into()))]);
handler
.call(
"notification_diagnostics",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP rejected allowed notification read"));
client.grants.lock().unwrap().clear();
assert!(matches!(
handler
.call(
"notification_diagnostics",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
#[tokio::test]
async fn notification_job_grant_rejects_other_job_and_revocation_without_downstream_read() {
let response = json!({"items":[],"next_cursor":null,"source_checks":[{"resource_kind":"job_result","reference":"Job-A","origin_present":true,"captured":true}]});
let client = AuthorizedClient::new(response.clone());
let (_, tool) = definition(client.clone())();
client.grant("notification.diagnostics.read", job("Job-A"));
assert!(
tool.execute(r#"{"job_id":"Job-B"}"#, ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("HTTP status 403")
);
assert!(client.requests.lock().unwrap().is_empty());
let output = tool
.execute(r#"{"job_id":"Job-A"}"#, ToolExecutionContext::direct())
.await
.unwrap();
assert_eq!(
serde_json::from_str::<Value>(output.content.as_deref().unwrap()).unwrap(),
response
);
let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::read_only()).unwrap();
let handler = TicketCollectionWipHandler {
tools: operation_map(
tools
.into_iter()
.filter(|tool| tool.projection.surface == NativeTicketSurface::Collection)
.collect(),
),
permissions: None,
collection_route: "/tickets".into(),
observations: Arc::new(Mutex::new(TicketObservationState::default())),
};
let args = BTreeMap::from([("job_id".into(), WipValue::String("Job-A".into()))]);
handler
.call(
"notification_diagnostics",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into(),
},
)
.await
.unwrap_or_else(|_| panic!("WIP rejected authorized Job diagnostics"));
client.requests.lock().unwrap().clear();
client.grants.lock().unwrap().clear();
assert!(
tool.execute(r#"{"job_id":"Job-A"}"#, ToolExecutionContext::direct())
.await
.unwrap_err()
.to_string()
.contains("HTTP status 403")
);
assert!(matches!(
handler
.call(
"notification_diagnostics",
&args,
WipCallContext {
execution: ToolExecutionContext::direct(),
security_context: "worker".into()
}
)
.await,
Err(WipOperationError::Protocol(ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
assert!(client.requests.lock().unwrap().is_empty());
let auth = client.authorizations.lock().unwrap();
assert_eq!(auth.len(), 5);
assert!(auth.iter().all(
|request| request.operation == "notification.diagnostics.read"
&& matches!(request.resource, PermissionResource::Job { .. })
));
assert_eq!(auth[0].resource, job("Job-B"));
assert!(
auth[1..]
.iter()
.all(|request| request.resource == job("Job-A"))
);
}
#[tokio::test]
async fn notification_source_uses_host_target_not_asserted_job_or_workspace_fallback() {
let client = tests::BoundClient::new();
let (_, tool) = definition(client.clone())();
client
.inner
.grant("notification.diagnostics.read", job("Job-A"));
for event in [
"backend-job-notification:hash-B",
"legacy-B",
"mr-event:legacy-B:e",
"missing",
"ambiguous",
] {
let input = json!({"job_id":"Job-A","source_event_id":event});
assert!(
tool.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.is_err()
);
}
assert!(client.inner.requests.lock().unwrap().is_empty());
for event in ["backend-job-notification:hash-A", "legacy-A"] {
let input = json!({"source_event_id":event});
let output = tool
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
let page: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(page["source_checks"][0]["captured"], true);
}
assert_eq!(client.inner.requests.lock().unwrap().len(), 2);
client.inner.grants.lock().unwrap().clear();
assert!(
tool.execute(
r#"{"source_event_id":"legacy-A"}"#,
ToolExecutionContext::direct()
)
.await
.is_err()
);
assert_eq!(client.inner.requests.lock().unwrap().len(), 2);
// A Workspace grant cannot authorize an individual Job source.
client
.inner
.grant("notification.diagnostics.read", workspace());
assert!(
tool.execute(
r#"{"source_event_id":"legacy-B"}"#,
ToolExecutionContext::direct(),
)
.await
.is_err()
);
assert_eq!(client.inner.requests.lock().unwrap().len(), 2);
client
.inner
.grant("notification.diagnostics.read", job("Job-B"));
tool.execute(
r#"{"source_event_id":"legacy-B"}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(client.bound.lock().unwrap().last().unwrap(), "legacy-B");
}
}
pub(super) fn definition(client: Arc<dyn WorkspaceClient>) -> ToolDefinition {
Arc::new(move || {
(
@@ -67,6 +67,14 @@ impl WorkspaceClient for Client {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path == "/api/w/workspace/authorization/check" {
let _: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
return Ok(WorkspaceResponse {
status: 200,
body: json!({"scope":null}).to_string(),
});
}
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: self.status,
@@ -157,3 +165,73 @@ async fn notification_tool_rejects_raw_or_undeclared_backend_content() {
assert!(!error.to_string().contains("SECRET"));
}
}
// Transport fixture for the Feature contract; real Host/store binding is covered
// by the Server router connection test, not claimed by this model of the Host.
#[derive(Debug)]
pub(super) struct BoundClient {
pub inner: Arc<assignment_tracking::authorization_tests::AuthorizedClient>,
pub bound: Mutex<Vec<String>>,
}
impl BoundClient {
pub fn new() -> Arc<Self> {
Arc::new(Self {
inner: assignment_tracking::authorization_tests::AuthorizedClient::new(json!({
"items":[],"next_cursor":null,"source_checks":[{
"resource_kind":"source_event","reference":"saved","origin_present":true,"captured":true
}]
})),
bound: Mutex::new(vec![]),
})
}
}
impl WorkspaceClient for BoundClient {
fn workspace_id(&self) -> Option<&str> {
Some("workspace")
}
fn kind(&self) -> &str {
"bound-notification-fixture"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
mut request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path == "/api/w/workspace/authorization/bound-check" {
use server_api::authorization::{
BoundAuthorizationTarget, BoundOperationAuthorizationRequest,
OperationAuthorizationRequest, PermissionResource,
};
let auth: BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let BoundAuthorizationTarget::NotificationSource { source_event_id } = auth.target
else {
panic!("wrong binding")
};
self.bound.lock().unwrap().push(source_event_id.clone());
let job_id = match source_event_id.as_str() {
"backend-job-notification:hash-A" | "legacy-A" => "Job-A",
"backend-job-notification:hash-B" | "legacy-B" | "mr-event:legacy-B:e" => "Job-B",
_ => return Ok(WorkspaceResponse {
status: 403,
body: json!({"message":"Notification source is unavailable or not authorized"})
.to_string(),
}),
};
request.path = "/api/w/workspace/authorization/check".into();
request.body = Some(
serde_json::to_string(&OperationAuthorizationRequest {
operation: auth.operation,
resource: PermissionResource::Job {
workspace_id: "workspace".into(),
job_id: job_id.into(),
},
})
.unwrap(),
);
}
self.inner.execute(request)
}
}
@@ -4,6 +4,7 @@ use crate::worker::{WorkspaceClientError, WorkspaceResponse};
#[derive(Debug)]
struct RecordingClient {
requests: Mutex<Vec<WorkspaceRequest>>,
authorizations: Mutex<std::collections::VecDeque<Value>>,
body: String,
identity: bool,
status: u16,
@@ -22,6 +23,14 @@ impl WorkspaceClient for RecordingClient {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path == "/api/w/workspace/authorization/check" {
let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(Some(body), self.authorizations.lock().unwrap().pop_front());
return Ok(WorkspaceResponse {
status: 200,
body: json!({"scope": null}).to_string(),
});
}
self.requests.lock().unwrap().push(request);
Ok(WorkspaceResponse {
status: self.status,
@@ -29,9 +38,19 @@ impl WorkspaceClient for RecordingClient {
})
}
}
fn recording(body: Value) -> Arc<RecordingClient> {
fn expected_authorization(operation: &str, ticket_id: Option<&str>) -> Value {
let resource = match ticket_id {
Some(ticket_id) => {
json!({"kind":"ticket", "workspace_id":"workspace", "ticket_id":ticket_id})
}
None => json!({"kind":"workspace", "workspace_id":"workspace"}),
};
json!({"operation":operation, "resource":resource})
}
fn recording(body: Value, expected: impl IntoIterator<Item = Value>) -> Arc<RecordingClient> {
Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
authorizations: Mutex::new(expected.into_iter().collect()),
body: body.to_string(),
identity: true,
status: 200,
@@ -69,7 +88,7 @@ fn cases() -> Vec<(TagToolKind, Value, &'static str)> {
#[tokio::test]
async fn classification_tool_schemas_require_observations_and_reject_actor_claims() {
for (kind, input, name) in cases() {
let client = recording(json!([]));
let client = recording(json!([]), []);
let (meta, tool) = definition(client.clone(), kind)();
assert_eq!(meta.name, name);
let validator = jsonschema::validator_for(&meta.input_schema).unwrap();
@@ -111,7 +130,10 @@ async fn classification_tool_schemas_require_observations_and_reject_actor_claim
#[tokio::test]
async fn classification_reads_forward_id_filters_and_preserve_tag_observations() {
let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]});
let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}));
let client = recording(
json!({"items":[item], "page":{"has_more":false, "next_cursor":null}}),
[expected_authorization("ticket.query", None)],
);
let (meta, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
@@ -122,6 +144,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
.execute(&input.to_string(), ToolExecutionContext::direct())
.await
.unwrap();
assert!(client.authorizations.lock().unwrap().is_empty());
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tickets"][0]["tags"], json!([tag()]));
assert_eq!(value["tickets"][0]["state"], "open");
@@ -140,13 +163,17 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
}
}
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "body_truncated":false, "targets":[], "initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Body","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null}, "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let client = recording(detail);
let client = recording(
detail,
[expected_authorization("ticket.read", Some("T-740"))],
);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
let output = tool
.execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct())
.await
.unwrap();
assert!(client.authorizations.lock().unwrap().is_empty());
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value["tags"], json!([tag()]));
assert_eq!(value["content_digest"], "content-1");
@@ -156,7 +183,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
assert!(value.as_object().unwrap().contains_key(field), "{field}");
assert_eq!(value[field], Value::Null);
}
let client = recording(json!({}));
let client = recording(json!({}), []);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)();
for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] {
@@ -190,7 +217,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
#[tokio::test]
async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() {
let client = recording(json!([]));
let client = recording(json!([]), []);
for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] {
let (_, tool) = definition(client.clone(), TagToolKind::Create)();
assert!(matches!(
@@ -244,6 +271,7 @@ async fn classification_tools_require_backend_workspace_and_bound_list_results()
let missing = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "[]".into(),
authorizations: Mutex::new(Default::default()),
identity: false,
status: 200,
});
@@ -256,7 +284,10 @@ async fn classification_tools_require_backend_workspace_and_bound_list_results()
));
}
assert!(missing.requests.lock().unwrap().is_empty());
let client = recording(json!(vec![tag(); 201]));
let client = recording(
json!(vec![tag(); 201]),
(0..3).map(|_| expected_authorization("ticket.tag.list", None)),
);
let (_, tool) = definition(client.clone(), TagToolKind::List)();
for (input, expected) in [
(json!({}), 200),
@@ -270,6 +301,7 @@ async fn classification_tools_require_backend_workspace_and_bound_list_results()
let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap();
assert_eq!(value.as_array().unwrap().len(), expected);
}
assert!(client.authorizations.lock().unwrap().is_empty());
let requests = client.requests.lock().unwrap();
assert_eq!(
requests[1].path,
@@ -279,7 +311,10 @@ async fn classification_tools_require_backend_workspace_and_bound_list_results()
#[tokio::test]
async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() {
let client = recording(json!([tag()]));
let client = recording(
json!([tag()]),
[expected_authorization("ticket.tags.set", Some("T-740"))],
);
let tools =
native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap();
let set = tools
@@ -377,6 +412,7 @@ async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_ident
.await
.is_err()
);
assert!(client.authorizations.lock().unwrap().is_empty());
assert_eq!(client.requests.lock().unwrap().len(), 1);
let read_only = TicketFeatureAccess::read_only().tool_names();
assert!(read_only.contains(&"WorkspaceTagList"));
@@ -389,6 +425,11 @@ async fn classification_tools_preserve_backend_permission_and_stale_set_failures
let client = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
body: "{}".into(),
authorizations: Mutex::new(
[expected_authorization("ticket.tags.set", Some("T-740"))]
.into_iter()
.collect(),
),
identity: true,
status,
});
@@ -399,6 +440,7 @@ async fn classification_tools_preserve_backend_permission_and_stale_set_failures
.await
.unwrap_err();
assert!(error.to_string().contains(&status.to_string()));
assert!(client.authorizations.lock().unwrap().is_empty());
assert_eq!(client.requests.lock().unwrap().len(), 1);
}
}
@@ -417,7 +459,19 @@ fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare(
let base = format!("http://{}", listener.local_addr().unwrap());
let server_db = db.clone();
let server = std::thread::spawn(move || {
for _ in 0..10 {
let mut authorizations = std::collections::VecDeque::from([
expected_authorization("ticket.tag.create", None),
expected_authorization("ticket.tag.create", None),
expected_authorization("ticket.tags.set", Some(&ticket_id)),
expected_authorization("ticket.tags.set", Some(&ticket_id)),
expected_authorization("ticket.tags.set", Some(&ticket_id)),
expected_authorization("ticket.tags.set", Some(&ticket_id)),
expected_authorization("ticket.tags.set", Some(&ticket_id)),
expected_authorization("ticket.tag.rename", None),
expected_authorization("ticket.tag.delete", None),
expected_authorization("ticket.tag.list", None),
]);
for _ in 0..20 {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(std::time::Duration::from_secs(10)))
@@ -446,7 +500,11 @@ fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare(
let backend =
ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap();
let line = headers.lines().next().unwrap();
let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") {
let result = if line.starts_with("POST /api/w/workspace/authorization/check ") {
let actual: Value = serde_json::from_slice(&body).unwrap();
assert_eq!(Some(actual), authorizations.pop_front());
Ok(json!({"scope": null}))
} else if line.starts_with("POST /api/w/workspace/ticket-tags ") {
backend
.mutate_workspace_tag(serde_json::from_slice(&body).unwrap())
.map(|tag| json!(tag))
@@ -475,6 +533,7 @@ fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare(
};
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap();
}
assert!(authorizations.is_empty());
});
let client: Arc<dyn WorkspaceClient> = Arc::new(crate::worker::TestWorkspaceHttpClient::new(
"workspace",
+18
View File
@@ -0,0 +1,18 @@
//! Host authorization for the concrete network operation, independent of Tool registration.
use std::sync::Arc;
use agen::tool::ToolError;
use async_trait::async_trait;
use crate::worker::WorkspaceClient;
pub(crate) struct WorkspaceWebAuthorizer(pub Arc<dyn WorkspaceClient>);
#[async_trait]
impl tools::WebOperationAuthorizer for WorkspaceWebAuthorizer {
async fn authorize(&self, operation: &str, url: &str) -> Result<(), ToolError> {
self.0
.authorize_network_operation(operation, url)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))
}
}
@@ -143,6 +143,14 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
"Workspace observation requires a scoped Workspace client".to_string(),
)
})?;
self.client
.authorize_operation(
"worker.observe.list",
&server_api::authorization::PermissionResource::Workspace {
workspace_id: _workspace_id.into(),
},
)
.map_err(workspace_client_error)?;
let response = self
.client
.execute_server_operation(
@@ -176,6 +184,22 @@ impl WorkerObservationProvider for WorkspaceClientWorkerObservationProvider {
"Workspace observation requires a scoped Workspace client".to_string(),
)
})?;
match subject {
WorkerObservationSubjectRef::RuntimeWorker {
runtime_id,
worker_id,
} => self.client.authorize_operation(
"worker.observe",
&server_api::authorization::PermissionResource::Worker {
runtime_id: runtime_id.clone(),
worker_id: worker_id.clone(),
},
),
WorkerObservationSubjectRef::SubWorker { name } => self
.client
.authorize_subworker_operation("subworker.observe", name),
}
.map_err(workspace_client_error)?;
let response = self
.client
.execute_server_operation(
@@ -0,0 +1,359 @@
//! Normal worker lib coverage from WIP/common backend through the real WorkspaceClient
//! authorization transport. The router substitutes Host/provider transport, using the shared
//! scoped-grant evaluator; it does not prove live Host identity, policy storage or delegation.
use super::*;
use server_api::authorization::{PermissionResource, ScopedPermission};
const A: &str = "main.dcdl";
const B: &str = "profiles/team/deep/model.dcdl";
fn grant(operation: &str, path: &str, recursive: bool) -> ScopedPermission {
ScopedPermission {
operation: format!("workspace_config.{operation}"),
resource: PermissionResource::WorkspaceConfig {
workspace_id: "workspace-1".into(),
path: path.into(),
recursive,
},
}
}
fn assert_code<T>(result: Result<T, WipOperationError>, expected: ProtocolErrorCode) {
match result {
Err(WipOperationError::Protocol(error)) => assert_eq!(error.code, expected),
_ => panic!("expected a classified protocol failure"),
}
}
fn provider_calls(client: &Router, suffix: &str) -> usize {
client
.state
.lock()
.unwrap()
.requests
.iter()
.filter(|request| request.path.ends_with(suffix))
.count()
}
#[tokio::test]
async fn wip_scoped_read_returns_allowed_content_denies_other_path_and_rechecks_revocation() {
let client = Router::new();
client.state.lock().unwrap().permissions = Some(vec![
grant("current", "", false),
grant("attach", "", false),
grant("observe", A, false),
grant("observe", B, false),
grant("read", A, false),
]);
let (feature, runtime) = runtime(client.clone());
feature
.attach("scoped", None)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
for path in [A, B] {
observe_interface(&runtime, &format!("{CONTENT_ROOT}/{path}"), true).await;
}
let output = call(&runtime, &format!("{CONTENT_ROOT}/{A}"), "read", json!({}))
.await
.unwrap()
.content
.unwrap();
assert!(output.contains("model = old"), "{output}");
assert!(!output.contains("model = deep"));
assert_eq!(provider_calls(&client, "/read"), 1);
let error = call(&runtime, &format!("{CONTENT_ROOT}/{B}"), "read", json!({}))
.await
.unwrap_err();
assert!(error.to_string().contains("PermissionDenied"), "{error}");
assert!(!error.to_string().contains("model = deep"));
assert_eq!(
provider_calls(&client, "/read"),
1,
"B must not reach provider read"
);
{
let mut state = client.state.lock().unwrap();
assert!(
state.authorizations.iter().any(|request| {
request.operation == "workspace_config.read"
&& request.resource == grant("read", B, false).resource
}),
"deny must occur at read authorization, not metadata discovery"
);
state
.permissions
.as_mut()
.unwrap()
.retain(|permission| permission.operation != "workspace_config.read");
}
let error = call(&runtime, &format!("{CONTENT_ROOT}/{A}"), "read", json!({}))
.await
.unwrap_err();
assert!(error.to_string().contains("PermissionDenied"), "{error}");
assert_eq!(
provider_calls(&client, "/read"),
1,
"prior successful read is not authority for next read"
);
assert_eq!(client.state.lock().unwrap().applied_commits, 0);
}
#[tokio::test]
async fn scoped_observe_requires_every_path_and_explicit_recursive_scope_before_dispatch() {
let client = Router::new();
let (feature, _) = runtime(client.clone());
feature
.attach("setup", None)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let connection = client
.state
.lock()
.unwrap()
.active
.as_ref()
.unwrap()
.connection_id
.clone();
client.state.lock().unwrap().permissions = Some(vec![grant("observe", A, false)]);
let before = client.state.lock().unwrap().requests.len();
assert_code(
backend
.post::<WorkspaceConfigObserveResponse, _>(
"/observe",
&json!({
"connection_id":connection, "paths":[A, B], "depth":0
}),
false,
)
.await,
ProtocolErrorCode::PermissionDenied,
);
assert_eq!(
client.state.lock().unwrap().requests.len(),
before,
"partial allow must not dispatch the batch"
);
client.state.lock().unwrap().permissions = Some(vec![grant("observe", "profiles", false)]);
let request = json!({"connection_id":connection, "paths":["profiles"], "depth":3});
assert_code(
backend
.post::<WorkspaceConfigObserveResponse, _>("/observe", &request, false)
.await,
ProtocolErrorCode::PermissionDenied,
);
assert_eq!(client.state.lock().unwrap().requests.len(), before);
client.state.lock().unwrap().permissions = Some(vec![grant("observe", "profiles", true)]);
let response: WorkspaceConfigObserveResponse = backend
.post("/observe", &request, false)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
assert!(response.nodes.iter().any(|node| node.path == B));
assert_eq!(client.state.lock().unwrap().requests.len(), before + 1);
}
#[tokio::test]
async fn current_authorization_denial_is_distinct_from_authority_outage_without_provider_access() {
for (status, code) in [
(401, ProtocolErrorCode::PermissionDenied),
(403, ProtocolErrorCode::PermissionDenied),
(503, ProtocolErrorCode::Internal),
] {
let client = Router::new();
client.state.lock().unwrap().permissions = Some(vec![]);
client.state.lock().unwrap().authorization_status = Some(status);
let backend = backend::WorkspaceConfigBackend::new(client.clone());
assert_code(backend.current_async().await, code);
let (_, runtime) = runtime(client.clone());
let message = runtime
.tree(format!("{CONTENT_ROOT}/{A}"), 0, true)
.await
.unwrap_err()
.to_string();
if status == 503 {
assert!(message.contains("Internal"), "{message}");
} else {
// Denied metadata is absent, not an authority/transport failure.
assert!(!message.contains("Internal"), "{message}");
assert!(!message.contains("model = old"));
}
let state = client.state.lock().unwrap();
assert!(
state.requests.is_empty(),
"status {status} reached provider"
);
assert!(state.active.is_none());
assert!(
state
.authorizations
.iter()
.all(|request| request.operation == "workspace_config.current"
&& request.resource == grant("current", "", false).resource)
);
}
}
fn commit_body(client: &Router, entrypoints: &[&str], changes: Json) -> Json {
let state = client.state.lock().unwrap();
json!({
"connection_id":state.active.as_ref().unwrap().connection_id,
"validator":state.validator(""),
"request":{
"base_digest":state.tree_digest(),
"entrypoints":entrypoints,
"changes":changes
}
})
}
#[tokio::test]
async fn entrypoint_replacement_requires_explicit_root_grant_even_without_file_changes() {
let client = Router::new();
let (feature, _) = runtime(client.clone());
feature
.attach("setup", None)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let request = commit_body(&client, &[B], json!([]));
client.state.lock().unwrap().permissions = Some(vec![grant("commit", B, false)]);
let before = client.state.lock().unwrap().requests.len();
assert_code(
backend
.post::<WorkspaceConfigCommitResponse, _>("/commit", &request, true)
.await,
ProtocolErrorCode::PermissionDenied,
);
{
let state = client.state.lock().unwrap();
assert_eq!(state.requests.len(), before);
assert_eq!(state.applied_commits, 0);
assert_eq!(state.entrypoints, [A]);
}
client.state.lock().unwrap().permissions = Some(vec![grant("commit", "", false)]);
let response: WorkspaceConfigCommitResponse = backend
.post("/commit", &request, true)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
{
let state = client.state.lock().unwrap();
assert_eq!(state.applied_commits, 1);
assert_eq!(state.entrypoints, [B]);
assert_eq!(state.entries[A].1, "model = old");
assert_eq!(response.digest, state.tree_digest());
assert_eq!(response.validator, state.validator(""));
}
// Confirm the replacement is returned by the provider, not just accepted as a request.
client.state.lock().unwrap().permissions = Some(vec![grant("observe", "", false)]);
let connection = client
.state
.lock()
.unwrap()
.active
.as_ref()
.unwrap()
.connection_id
.clone();
let observed: WorkspaceConfigObserveResponse = backend
.post(
"/observe",
&json!({"connection_id":connection, "paths":[""], "depth":0}),
false,
)
.await
.unwrap_or_else(|_| panic!("authorized observation failed"));
assert_eq!(observed.entrypoints, [B]);
}
#[tokio::test]
async fn commit_requires_root_and_each_file_grant_including_both_rename_ends() {
let cases = [
(
"create",
json!({"kind":"create", "path":"new.dcdl", "content_type":"decodal", "content":"model = new"}),
vec!["", "new.dcdl"],
),
(
"update",
json!({"kind":"update", "path":B, "expected_digest":digest("model = deep"), "content":"model = changed"}),
vec!["", B],
),
(
"delete",
json!({"kind":"delete", "path":B, "expected_digest":digest("model = deep")}),
vec!["", B],
),
(
"rename",
json!({"kind":"rename", "from":B, "to":"destination.dcdl", "expected_digest":digest("model = deep")}),
vec!["", B, "destination.dcdl"],
),
];
for (kind, change, required) in cases {
let client = Router::new();
let (feature, _) = runtime(client.clone());
feature
.attach("setup", None)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let request = commit_body(&client, &[A], json!([change]));
let before = client.state.lock().unwrap().requests.len();
let original = client.state.lock().unwrap().entries.clone();
for missing in &required {
client.state.lock().unwrap().permissions = Some(
required
.iter()
.filter(|path| *path != missing)
.map(|path| grant("commit", path, false))
.collect(),
);
assert_code(
backend
.post::<WorkspaceConfigCommitResponse, _>("/commit", &request, true)
.await,
ProtocolErrorCode::PermissionDenied,
);
let state = client.state.lock().unwrap();
assert_eq!(state.requests.len(), before, "{kind}, missing {missing:?}");
assert!(state.commits.is_empty());
assert_eq!(state.applied_commits, 0);
assert_eq!(state.entries, original);
assert_eq!(
state.authorizations.last().unwrap().resource,
grant("commit", missing, false).resource
);
}
// No recursive root grant: each exact file grant is independently necessary.
client.state.lock().unwrap().permissions = Some(
required
.iter()
.map(|path| grant("commit", path, false))
.collect(),
);
let response: WorkspaceConfigCommitResponse = backend
.post("/commit", &request, true)
.await
.unwrap_or_else(|_| panic!("authorized provider operation failed"));
let state = client.state.lock().unwrap();
assert_eq!(state.applied_commits, 1, "{kind}");
assert_eq!(state.requests.len(), before + 1);
assert_eq!(response.digest, state.tree_digest());
assert_eq!(response.validator, state.validator(""));
assert_eq!(state.entries[A], original[A]);
match kind {
"create" => assert_eq!(state.entries["new.dcdl"].1, "model = new"),
"update" => assert_eq!(state.entries[B].1, "model = changed"),
"delete" => assert!(!state.entries.contains_key(B)),
"rename" => {
assert!(!state.entries.contains_key(B));
assert_eq!(state.entries["destination.dcdl"], original[B]);
}
_ => unreachable!(),
}
}
}
@@ -11,9 +11,11 @@ use std::time::Duration;
use async_trait::async_trait;
use serde::{Serialize, de::DeserializeOwned};
use server_api::authorization::PermissionResource;
use server_api::{
WorkspaceConfigAccess, WorkspaceConfigApiError, WorkspaceConfigAttachRequest,
WorkspaceConfigAttachment, WorkspaceConfigFailureClassification,
ConfigTreeChange, WorkspaceConfigAccess, WorkspaceConfigApiError, WorkspaceConfigAttachRequest,
WorkspaceConfigAttachment, WorkspaceConfigCommitRequest, WorkspaceConfigFailureClassification,
WorkspaceConfigObserveRequest, WorkspaceConfigReadRequest,
};
use wip_protocol::{ProtocolError, ProtocolErrorCode};
@@ -53,6 +55,96 @@ impl WorkspaceConfigBackend {
self
}
fn authorize(
&self,
operation: &str,
path: &str,
recursive: bool,
) -> Result<(), WipOperationError> {
let workspace_id = self
.client
.workspace_id()
.filter(|id| !id.is_empty())
.ok_or_else(|| {
failure(
ProtocolErrorCode::PermissionDenied,
"Workspace config authority unavailable",
)
})?;
let resource = PermissionResource::WorkspaceConfig {
workspace_id: workspace_id.into(),
path: path.into(),
recursive,
};
self.client
.authorize_operation(operation, &resource)
.map_err(|error| {
let denied = matches!(&error, WorkspaceClientError::Request(message)
if message.contains("HTTP status 403") || message.contains("HTTP status 401"));
failure(
if denied {
ProtocolErrorCode::PermissionDenied
} else {
ProtocolErrorCode::Internal
},
&format!("Workspace config operation authorization failed before dispatch: {operation}; resource={resource:?}; denied={denied}"),
)
})
}
fn authorize_post(&self, suffix: &str, body: &str) -> Result<(), WipOperationError> {
let invalid = || {
failure(
ProtocolErrorCode::InvalidArguments,
"Invalid Workspace config operation",
)
};
match suffix {
"" => {
let _: WorkspaceConfigAttachRequest =
serde_json::from_str(body).map_err(|_| invalid())?;
self.authorize("workspace_config.attach", "", false)
}
"/observe" => {
let request: WorkspaceConfigObserveRequest =
serde_json::from_str(body).map_err(|_| invalid())?;
if request.paths.is_empty() {
return Err(invalid());
}
for path in request.paths {
self.authorize("workspace_config.observe", &path, request.depth > 0)?;
}
Ok(())
}
"/read" => {
let request: WorkspaceConfigReadRequest =
serde_json::from_str(body).map_err(|_| invalid())?;
self.authorize("workspace_config.read", &request.path, false)
}
"/commit" => {
let request: WorkspaceConfigCommitRequest =
serde_json::from_str(body).map_err(|_| invalid())?;
// The commit can also replace the whole-tree entrypoint list.
self.authorize("workspace_config.commit", "", false)?;
for change in request.request.changes {
match change {
ConfigTreeChange::Create { path, .. }
| ConfigTreeChange::Update { path, .. }
| ConfigTreeChange::Delete { path, .. } => {
self.authorize("workspace_config.commit", &path, false)?
}
ConfigTreeChange::Rename { from, to, .. } => {
self.authorize("workspace_config.commit", &from, false)?;
self.authorize("workspace_config.commit", &to, false)?;
}
}
}
Ok(())
}
_ => Err(invalid()),
}
}
fn base(&self) -> Result<String, WipOperationError> {
let workspace = self
.client
@@ -177,6 +269,7 @@ impl WorkspaceConfigBackend {
&self,
diagnostic: &Diagnostic,
) -> Result<Option<WorkspaceConfigAttachment>, WipOperationError> {
self.authorize("workspace_config.current", "", false)?;
let result: Option<WorkspaceConfigAttachment> =
self.request(WorkspaceRequest::get(self.base()?), false, diagnostic)?;
if let Some(attachment) = &result {
@@ -247,6 +340,7 @@ impl WorkspaceConfigBackend {
"Workspace config request exceeds limit",
));
}
self.authorize_post(suffix, &body)?;
let request = WorkspaceRequest::json(
WorkspaceRequestMethod::Post,
format!("{}{suffix}", self.base()?),
@@ -7,6 +7,14 @@ use crate::worker::WorkspaceResponse;
use std::sync::{Condvar, Mutex};
use tracing::instrument::WithSubscriber;
fn operation_body(mutation: bool) -> serde_json::Value {
if mutation {
serde_json::json!({"connection_id":"connection-1", "validator":"v", "request":{"base_digest":"d", "entrypoints":["main.dcdl"], "changes":[{"kind":"update", "path":"main.dcdl", "expected_digest":"d", "content":UNSAFE}]}})
} else {
serde_json::json!({"connection_id":"connection-1", "validator":"v", "path":"main.dcdl"})
}
}
const UNSAFE: &str = "secret-token /private/config credential=unsafe";
#[derive(Clone, Copy, Debug)]
@@ -105,6 +113,14 @@ impl WorkspaceClient for Client {
&self,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
return Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": input.resource}).to_string(),
});
}
{
let mut state = self.state.lock().unwrap();
state.requests += 1;
@@ -292,7 +308,7 @@ async fn production_reply_failures_retain_reasons_without_retrying_mutations() {
let result = backend
.post::<serde_json::Value, _>(
if mutation { "/commit" } else { "/read" },
&serde_json::json!({"content": UNSAFE}),
&operation_body(mutation),
mutation,
)
.with_subscriber(capture.dispatch())
@@ -331,7 +347,7 @@ async fn production_typed_rejections_preserve_public_codes_and_never_apply() {
backend
.post::<serde_json::Value, _>(
if mutation { "/commit" } else { "/read" },
&(),
&operation_body(mutation),
mutation,
)
.with_subscriber(capture.dispatch())
@@ -356,7 +372,7 @@ async fn production_deadline_is_unknown_after_effect_and_blocking_completion_is_
let backend =
WorkspaceConfigBackend::new(client.clone()).with_deadline(Duration::from_secs(1));
let capture = Capture::default();
let body = serde_json::json!({"content": UNSAFE});
let body = operation_body(mutation);
let request = backend
.post::<serde_json::Value, _>(
if mutation { "/commit" } else { "/read" },
@@ -6,7 +6,7 @@ use super::*;
use crate::feature::FeatureRegistryBuilder;
use crate::feature::builtin::backend_diagnostic::tests::Capture;
use crate::hook::HookRegistryBuilder;
use crate::wip::{WipMountRegistry, WipRuntime};
use crate::wip::{WipMountRegistry, WipOperationError, WipRuntime};
use crate::worker::{
WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod,
WorkspaceResponse,
@@ -19,9 +19,16 @@ use server_api::*;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::{Arc, Mutex};
use tracing::instrument::WithSubscriber;
use wip_protocol::ProtocolErrorCode;
#[derive(Debug)]
struct State {
operation_denied: bool,
denied_path: Option<String>,
permissions: Option<Vec<server_api::authorization::ScopedPermission>>,
authorization_status: Option<u16>,
entrypoints: Vec<String>,
authorizations: Vec<server_api::authorization::OperationAuthorizationRequest>,
granted: bool,
read_only: bool,
active: Option<WorkspaceConfigAttachment>,
@@ -49,6 +56,12 @@ impl Router {
Arc::new(Self {
workspace: "workspace-1",
state: Arc::new(Mutex::new(State {
operation_denied: false,
denied_path: None,
permissions: None,
authorization_status: None,
entrypoints: vec!["main.dcdl".into()],
authorizations: Vec::new(),
granted: true,
read_only: false,
active: None,
@@ -170,6 +183,39 @@ impl WorkspaceClient for Router {
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
let mut state = self.state.lock().unwrap();
if request.path.ends_with("/authorization/check") {
let input: server_api::authorization::OperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
state.authorizations.push(input.clone());
let path_denied = matches!(&input.resource, server_api::authorization::PermissionResource::WorkspaceConfig {path, ..} if state.denied_path.as_ref() == Some(path));
let needed = server_api::authorization::ScopedPermission {
operation: input.operation.clone(),
resource: input.resource.clone(),
};
let scope = state
.permissions
.as_ref()
.map(|permissions| permissions.iter().find(|grant| grant.covers(&needed)));
let status = state.authorization_status.unwrap_or_else(|| {
if state.operation_denied || path_denied || matches!(scope, Some(None)) {
403
} else {
200
}
});
return Ok(if status != 200 {
WorkspaceResponse {
status,
body: "{}".into(),
}
} else {
let resource = scope
.flatten()
.map(|grant| &grant.resource)
.unwrap_or(&input.resource);
response(&serde_json::json!({"scope": resource}))
});
}
state.requests.push(request.clone());
let prefix = "/api/w/workspace-1/workers/self/workspace-config";
if self.workspace != "workspace-1" || !request.path.starts_with(prefix) || !state.granted {
@@ -288,7 +334,7 @@ impl WorkspaceClient for Router {
connection_id: state.active.as_ref().unwrap().connection_id.clone(),
validator: state.validator(""),
digest: state.tree_digest(),
entrypoints: vec!["main.dcdl".into()],
entrypoints: state.entrypoints.clone(),
nodes,
};
match state.malformed_observe_reply {
@@ -344,7 +390,6 @@ impl WorkspaceClient for Router {
WorkspaceConfigFailureClassification::NotCommitted,
));
}
assert_eq!(request.request.entrypoints, ["main.dcdl"]);
if request.validator != state.validator("")
|| request.request.base_digest != state.tree_digest()
{
@@ -436,6 +481,7 @@ impl WorkspaceClient for Router {
));
}
state.entries = entries;
state.entrypoints = request.request.entrypoints;
state.applied_commits += 1;
if state.lose_commit_reply {
state.lose_commit_reply = false;
@@ -1302,3 +1348,125 @@ async fn production_postcommit_transport_loss_retains_cause_and_unknown_without_
"saved-before-observation-loss"
);
}
#[path = "authorization_tests.rs"]
mod authorization_tests;
#[tokio::test]
async fn denied_config_operations_have_no_provider_access_or_attachment_effects() {
for operation in ["current", "attach", "observe", "read", "commit"] {
let client = Router::new();
client.state.lock().unwrap().operation_denied = true;
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let result = match operation {
"current" => backend.current_async().await.map(|_| ()),
"attach" => backend.post::<Json, _>("", &json!({"alias":"workspace-config", "access":"read_only"}), true).await.map(|_| ()),
"observe" => backend.post::<Json, _>("/observe", &json!({"connection_id":"c", "paths":["profiles"], "depth":1}), false).await.map(|_| ()),
"read" => backend.post::<Json, _>("/read", &json!({"connection_id":"c", "path":"main.dcdl", "validator":"v"}), false).await.map(|_| ()),
"commit" => backend.post::<Json, _>("/commit", &json!({"connection_id":"c", "validator":"v", "request":{"base_digest":"d", "entrypoints":["main.dcdl"], "changes":[{"kind":"delete", "path":"main.dcdl", "expected_digest":"d"}]}}), true).await.map(|_| ()),
_ => unreachable!(),
};
assert!(
matches!(
result,
Err(WipOperationError::Protocol(wip_protocol::ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
),
"{operation}"
);
let state = client.state.lock().unwrap();
assert!(state.requests.is_empty());
assert!(state.active.is_none());
assert_eq!(state.applied_commits, 0);
assert_eq!(state.authorizations.len(), 1);
assert_eq!(
state.authorizations[0].operation,
format!("workspace_config.{operation}")
);
assert_eq!(
state.authorizations[0].resource,
server_api::authorization::PermissionResource::WorkspaceConfig {
workspace_id: "workspace-1".into(),
path: match operation {
"observe" => "profiles",
"read" => "main.dcdl",
_ => "",
}
.into(),
recursive: operation == "observe"
}
);
}
}
#[tokio::test]
async fn config_wip_read_and_attach_replay_recheck_current_operation_authority() {
let client = Router::new();
let (feature, runtime) = runtime(client.clone());
feature
.attach("initial", Some(ConfigAccess::ReadOnly))
.await
.unwrap();
observe_interface(&runtime, "/workspace-config/main.dcdl", true).await;
call(&runtime, "/workspace-config/main.dcdl", "read", json!({}))
.await
.unwrap();
let requests = {
let mut state = client.state.lock().unwrap();
assert!(
state
.authorizations
.iter()
.any(|a| a.operation == "workspace_config.read")
);
state.operation_denied = true;
state.requests.len()
};
assert!(
call(&runtime, "/workspace-config/main.dcdl", "read", json!({}))
.await
.is_err()
);
assert_eq!(
feature
.attach("initial", Some(ConfigAccess::ReadOnly))
.await
.unwrap_err(),
ConfigAttachError::Denied
);
let state = client.state.lock().unwrap();
assert_eq!(state.requests.len(), requests);
assert_eq!(state.applied_commits, 0);
}
#[tokio::test]
async fn config_rename_checks_both_paths_before_commit() {
let client = Router::new();
client.state.lock().unwrap().denied_path = Some("destination.dcdl".into());
let backend = backend::WorkspaceConfigBackend::new(client.clone());
let result = backend.post::<Json, _>("/commit", &json!({"connection_id":"c", "validator":"v", "request":{"base_digest":"d", "entrypoints":["main.dcdl"], "changes":[{"kind":"rename", "from":"main.dcdl", "to":"destination.dcdl", "expected_digest":"d"}]}}), true).await;
assert!(matches!(
result,
Err(WipOperationError::Protocol(wip_protocol::ProtocolError {
code: ProtocolErrorCode::PermissionDenied,
..
}))
));
let state = client.state.lock().unwrap();
assert!(state.requests.is_empty());
assert_eq!(state.applied_commits, 0);
assert_eq!(
state
.authorizations
.iter()
.map(|a| match &a.resource {
server_api::authorization::PermissionResource::WorkspaceConfig { path, .. } =>
path.as_str(),
_ => panic!("wrong resource"),
})
.collect::<Vec<_>>(),
["", "main.dcdl", "destination.dcdl"]
);
}
@@ -119,6 +119,19 @@ impl Tool for ListWorkspaceWorkersTool {
));
}
let workspace_id = self.client.workspace_id().ok_or_else(|| {
ToolError::ExecutionFailed(
"Workspace Worker discovery requires Workspace identity".into(),
)
})?;
self.client
.authorize_operation(
"worker.discover",
&server_api::authorization::PermissionResource::Workspace {
workspace_id: workspace_id.into(),
},
)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
let page = self
.client
.list_workspace_workers(WorkspaceWorkerDiscoveryRequest {
@@ -178,6 +191,8 @@ mod tests {
#[derive(Debug)]
struct RecordingClient {
requests: Mutex<Vec<WorkspaceWorkerDiscoveryRequest>>,
authorizations: Mutex<Vec<server_api::authorization::OperationAuthorizationRequest>>,
denied: std::sync::atomic::AtomicBool,
result: WorkspaceWorkerDiscoveryPage,
unavailable: bool,
}
@@ -197,9 +212,24 @@ mod tests {
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
panic!("discovery must not use generic Workspace request authority")
assert!(
request.path.ends_with("/authorization/check"),
"discovery must use dedicated source proof, not generic request authority"
);
self.authorizations
.lock()
.unwrap()
.push(serde_json::from_str(request.body.as_deref().unwrap()).unwrap());
Ok(WorkspaceResponse {
status: if self.denied.load(std::sync::atomic::Ordering::SeqCst) {
403
} else {
200
},
body: r#"{"scope":null}"#.into(),
})
}
fn list_workspace_workers(
@@ -235,6 +265,8 @@ mod tests {
async fn tool_preserves_typed_subject_and_forwards_lookup() {
let client = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
authorizations: Mutex::default(),
denied: std::sync::atomic::AtomicBool::new(false),
result: page(),
unavailable: false,
});
@@ -263,6 +295,8 @@ mod tests {
async fn missing_backend_authority_fails_closed() {
let client = Arc::new(RecordingClient {
requests: Mutex::new(Vec::new()),
authorizations: Mutex::default(),
denied: std::sync::atomic::AtomicBool::new(false),
result: page(),
unavailable: true,
});
@@ -270,6 +304,53 @@ mod tests {
.execute("{}", ToolExecutionContext::default())
.await
.unwrap_err();
assert!(error.to_string().contains("denied"));
assert!(error.to_string().contains("Host-bound"));
}
#[tokio::test]
async fn discovery_checks_live_authority_before_protected_read() {
let client = Arc::new(RecordingClient {
requests: Mutex::default(),
authorizations: Mutex::default(),
denied: std::sync::atomic::AtomicBool::new(true),
result: page(),
unavailable: false,
});
let tool = ListWorkspaceWorkersTool {
client: client.clone(),
};
assert!(
tool.execute("{}", ToolExecutionContext::direct())
.await
.is_err()
);
assert!(client.requests.lock().unwrap().is_empty());
client
.denied
.store(false, std::sync::atomic::Ordering::SeqCst);
assert!(
tool.execute("{}", ToolExecutionContext::direct())
.await
.is_ok()
);
client
.denied
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(
tool.execute("{}", ToolExecutionContext::direct())
.await
.is_err()
);
assert_eq!(client.requests.lock().unwrap().len(), 1);
let authorizations = client.authorizations.lock().unwrap();
assert_eq!(authorizations.len(), 3);
for request in authorizations.iter() {
assert_eq!(request.operation, "worker.discover");
assert_eq!(
request.resource,
server_api::authorization::PermissionResource::Workspace {
workspace_id: "workspace-1".into()
}
);
}
}
}
+37
View File
@@ -114,6 +114,7 @@ pub(crate) struct WorkerInterceptor {
log_writer: Option<Arc<dyn SystemItemCommitter>>,
notification_committer: Option<Arc<dyn DurableNotificationCommitter>>,
resident_context_source: Option<Arc<dyn SystemPromptContributionSource>>,
protected_input_supply: crate::worker::ProtectedInputSupply,
pending_committed_history: Arc<Mutex<VecDeque<HistoryEntry<SessionHistoryMetadata>>>>,
/// Next turn index assigned by `on_prompt_submit`.
next_turn_index: AtomicUsize,
@@ -171,6 +172,7 @@ impl WorkerInterceptor {
log_writer,
notification_committer: None,
resident_context_source: None,
protected_input_supply: Default::default(),
pending_committed_history,
next_turn_index: AtomicUsize::new(0),
tool_calls_this_turn: AtomicUsize::new(0),
@@ -207,10 +209,20 @@ impl WorkerInterceptor {
mut self,
source: Option<Arc<dyn SystemPromptContributionSource>>,
) -> Self {
self.protected_input_supply
.set_prompt_source(source.clone());
self.resident_context_source = source;
self
}
pub(crate) fn with_protected_input_supply(
mut self,
supply: crate::worker::ProtectedInputSupply,
) -> Self {
self.protected_input_supply = supply;
self
}
pub(crate) fn with_prompt_workspace_id(mut self, workspace_id: Option<String>) -> Self {
self.prompt_workspace_id = workspace_id;
self
@@ -577,6 +589,15 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
&self,
context: PreLlmRequestContext<'_, SessionHistoryMetadata>,
) -> InterceptorResult<PreRequestAction> {
self.protected_input_supply
.authorize(context.history.iter().map(|entry| &entry.annotation))
.await
.map_err(|reason| {
InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("protected input supply rejected: {reason}"),
)
})?;
let context_invocation = context.invocation;
let context_history = context.history;
let context = context.items;
@@ -645,6 +666,18 @@ impl Interceptor<SessionHistoryMetadata> for WorkerInterceptor {
for entry in &mut system_items {
self.attach_prompt_provenance(std::slice::from_mut(&mut entry.item));
}
// Hooks may add protected input or revoke authority while preparing this
// request. Check their complete output before it can reach the provider,
// not only the history that existed before the hooks ran.
self.protected_input_supply
.authorize_pending(system_items.iter().map(|entry| &entry.metadata))
.await
.map_err(|reason| {
InterceptorError::new(
InterceptorErrorCategory::Dependency,
format!("protected input supply rejected: {reason}"),
)
})?;
let mut appended_items: Vec<Item> = system_items
.iter()
.map(|entry| entry.item.to_history_item())
@@ -1023,6 +1056,10 @@ mod tests {
#[async_trait]
impl SystemPromptContributionSource for ConfirmRecordingResidentSource {
fn protected_input_kind(&self) -> Option<&'static str> {
Some("subject-resident")
}
async fn load(&self) -> SystemPromptContribution {
SystemPromptContribution::Unavailable
}
+32 -11
View File
@@ -425,6 +425,10 @@ impl Tool for SubWorkerSpawnTool {
)));
}
validate_reviewer_handoff(&input)?;
self.workspace_context
.client()
.authorize_subworker_operation("subworker.spawn", &input.name)
.map_err(|error| ToolError::ExecutionFailed(error.to_string()))?;
let name_reservation = self
.registry
.reserve_internal_name(input.name.clone())
@@ -1344,7 +1348,9 @@ enabled = false
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
Arc::new(AvailableWorkspaceClient),
Arc::new(AvailableWorkspaceClient {
child_names: &["reviewer-child", "reviewer-child-parent-drop"],
}),
);
let calls = Arc::new(AtomicUsize::new(0));
let observed_parent_write_revoked = Arc::new(AtomicBool::new(false));
@@ -1801,7 +1807,9 @@ enabled = false
let registry = SpawnedWorkerRegistry::new_internal("parent".into(), spawner_scope.clone());
let workspace_context = crate::worker::WorkerWorkspaceContext::with_client(
Some(WorkspaceId::new("workspace-test").unwrap()),
Arc::new(AvailableWorkspaceClient),
Arc::new(AvailableWorkspaceClient {
child_names: &["remote-child"],
}),
);
let remote_client = Arc::new(StrictRemoteWorkdirWorkspaceClient::default());
let workdir_tool_broker = workdir::WorkdirToolBrokerRouter::from_single(
@@ -1989,7 +1997,9 @@ enabled = false
}
#[derive(Debug)]
struct AvailableWorkspaceClient;
struct AvailableWorkspaceClient {
child_names: &'static [&'static str],
}
impl WorkspaceClient for AvailableWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
@@ -2000,21 +2010,32 @@ 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
}
fn execute(
&self,
_request: WorkspaceRequest,
request: WorkspaceRequest,
) -> Result<WorkspaceResponse, WorkspaceClientError> {
Err(WorkspaceClientError::Unavailable("not invoked".into()))
assert_eq!(
request.path,
"/api/w/workspace-test/authorization/bound-check"
);
let body: serde_json::Value =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert!(
self.child_names.iter().any(|name| body
== serde_json::json!({
"operation": "subworker.spawn",
"target": {"kind": "sub_worker", "name": name}
})),
"unexpected bound authorization: {body}"
);
Ok(WorkspaceResponse {
status: 200,
body: serde_json::json!({"scope": null}).to_string(),
})
}
}
+301 -48
View File
@@ -37,8 +37,6 @@ impl<C: agen::llm_client::client::LlmClient + 'static, St: session_store::Store>
&& self.backend_job().is_none()
&& crate::feature::builtin::memory::ordinary_subjektiv_features_enabled(self.manifest())
&& let Ok(Some(connection)) = self.subjektiv_host_connection()
&& (connection.host.journal_startup_granted()
|| connection.host.journal_compaction_granted())
{
self.install_session_context_source(Arc::new(
crate::feature::builtin::journal::JournalContextSource::new(
@@ -70,6 +68,8 @@ pub enum SubjektivHostError {
Conflict { code: String, message: String },
#[error("{0}")]
InvalidArgument(String),
#[error("Subjektiv operation permission denied: {0}")]
PermissionDenied(String),
#[error("{0}")]
Unavailable(String),
}
@@ -81,6 +81,9 @@ impl From<SubjektivHostError> for ToolError {
Self::StructuredConflict { code, message }
}
SubjektivHostError::InvalidArgument(message) => Self::InvalidArgument(message),
SubjektivHostError::PermissionDenied(message) => {
Self::ExecutionFailed(format!("Subjektiv operation permission denied: {message}"))
}
SubjektivHostError::Unavailable(message) => Self::ExecutionFailed(message),
}
}
@@ -92,6 +95,17 @@ impl From<SubjektivHostError> for ToolError {
/// identifier is not a grant. No method grants ambient filesystem access.
pub trait SubjektivHost: Send + Sync + 'static {
fn settings(&self) -> SubjektivHostSettings;
/// Revalidate current authority before protected access or prompt supply.
/// Standalone Hosts must explicitly implement their local permission policy.
fn authorize_operation(
&self,
_context: &SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
Err(SubjektivHostError::PermissionDenied(format!(
"operation `{operation}` has no explicit Host authorization policy"
)))
}
/// Startup reads are a body-only capability, independent of recording Jobs.
fn journal_startup_granted(&self) -> bool {
false
@@ -233,6 +247,9 @@ fn decode<T: serde::de::DeserializeOwned>(
.as_ref()
.map(|error| error.message.clone())
.unwrap_or(response.body);
if matches!(response.status, 401 | 403) {
return Err(SubjektivHostError::PermissionDenied(message));
}
if let Some(code) = parsed.as_ref().and_then(|error| {
error
.diagnostics
@@ -267,6 +284,25 @@ fn decode<T: serde::de::DeserializeOwned>(
}
impl SubjektivHost for BackendSubjektivHost {
fn authorize_operation(
&self,
_context: &SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
self.client
.authorize_subject_operation(operation)
.map_err(|error| match error {
// WorkspaceClient preserves a distinct denial classification for
// 401/403. Transport and other HTTP errors must remain unavailable.
crate::WorkspaceClientError::Request(message)
if message.starts_with("bound authorization denied ") =>
{
SubjektivHostError::PermissionDenied(message)
}
other => SubjektivHostError::Unavailable(other.to_string()),
})
}
fn memory_journal(
&self,
_context: &SubjektivHostContext,
@@ -365,9 +401,13 @@ impl SubjektivHost for BackendSubjektivHost {
}
fn memory(
&self,
_context: &SubjektivHostContext,
context: &SubjektivHostContext,
operation: SubjektivMemoryBackendOperation,
) -> Result<SubjektivMemoryBackendResponse, SubjektivHostError> {
self.authorize_operation(
context,
crate::feature::builtin::memory::subject_memory_operation_name(&operation),
)?;
// Preserve the bounded resident-context request timeout.
if matches!(
operation,
@@ -400,8 +440,11 @@ impl SubjektivHost for BackendSubjektivHost {
SubjektivSessionBackendRequest { operation },
))
.map_err(|error| SubjektivHostError::Unavailable(error.to_string()))?;
// Session domain diagnostics are typed even on non-2xx responses.
if let Ok(value) = serde_json::from_str(&response.body) {
// Session domain diagnostics are typed even on non-2xx responses, but
// authentication/permission rejection must never become a data response.
if !matches!(response.status, 401 | 403)
&& let Ok(value) = serde_json::from_str(&response.body)
{
return Ok(value);
}
decode(response)
@@ -411,6 +454,7 @@ impl SubjektivHost for BackendSubjektivHost {
context: &SubjektivHostContext,
operation: MemoryStageCandidateOperation,
) -> Result<SubjektivStageCandidateResponse, SubjektivHostError> {
self.authorize_operation(context, "memory.staging.write")?;
self.execute(WorkspaceServerOperation::SubjektivStageCandidate(
SubjektivStageCandidateRequest {
session_id: context.session_id.clone(),
@@ -432,9 +476,10 @@ impl SubjektivHost for BackendSubjektivHost {
}
fn request_consolidation(
&self,
_context: &SubjektivHostContext,
context: &SubjektivHostContext,
operation: MemoryConsolidateStagingOperation,
) -> Result<MemoryConsolidationOutput, SubjektivHostError> {
self.authorize_operation(context, "memory.consolidation.request")?;
let response = self
.client
.execute(WorkspaceRequest::json(
@@ -538,6 +583,10 @@ pub struct SubjektivHostConnection {
}
impl SubjektivHostConnection {
pub fn authorize_operation(&self, operation: &str) -> Result<(), SubjektivHostError> {
self.host.authorize_operation(&self.context, operation)
}
pub fn journal_startup_context(&self) -> Result<JournalStartupSnapshot, SubjektivHostError> {
if !self.host.journal_startup_granted() {
return Err(SubjektivHostError::Unavailable(
@@ -674,6 +723,37 @@ pub(crate) fn test_connection(client: Arc<dyn WorkspaceClient>) -> SubjektivHost
.unwrap()
}
#[cfg(test)]
pub(crate) fn test_subject_authorization_response(
request: &WorkspaceRequest,
workspace_id: &str,
allowed: &[&str],
) -> Option<crate::worker::WorkspaceResponse> {
use server_api::authorization::{BoundAuthorizationTarget, BoundOperationAuthorizationRequest};
if !request.path.ends_with("/authorization/bound-check") {
return None;
}
assert_eq!(request.method, WorkspaceRequestMethod::Post);
assert_eq!(
request.path,
format!("/api/w/{workspace_id}/authorization/bound-check")
);
let input: BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.target, BoundAuthorizationTarget::Subject {});
let granted = allowed.contains(&input.operation.as_str());
Some(crate::worker::WorkspaceResponse {
status: if granted { 200 } else { 403 },
body: if granted {
serde_json::json!({
"scope": {"kind": "subject", "workspace_id": workspace_id, "subject_id": "subject-fixture"}
}).to_string()
} else {
serde_json::json!({"message": "operation not granted by Subject fixture"}).to_string()
},
})
}
#[cfg(test)]
mod tests {
use super::*;
@@ -696,10 +776,25 @@ mod tests {
startup_calls: std::sync::atomic::AtomicUsize,
contexts: Mutex<Vec<SubjektivHostContext>>,
attributed: Mutex<Vec<(SubjektivHostContext, bool)>>,
journal_operations: Vec<&'static str>,
journal_observer:
Option<Arc<dyn Fn(&SubjektivHostContext, JournalRecordingReason) + Send + Sync>>,
}
impl SubjektivHost for LocalHost {
fn authorize_operation(
&self,
_context: &SubjektivHostContext,
operation: &str,
) -> Result<(), SubjektivHostError> {
match operation {
"memory.resident.read" | "memory.resident.supply" => Ok(()),
operation if self.journal_operations.contains(&operation) => Ok(()),
_ => Err(SubjektivHostError::PermissionDenied(format!(
"test local Host did not grant `{operation}`"
))),
}
}
fn journal_startup_granted(&self) -> bool {
self.startup.is_some() || self.startup_error
}
@@ -801,6 +896,131 @@ mod tests {
}
}
#[derive(Debug)]
struct BoundAuthorizationClient {
status: u16,
requests: Mutex<Vec<WorkspaceRequest>>,
}
impl WorkspaceClient for BoundAuthorizationClient {
fn workspace_id(&self) -> Option<&str> {
Some("test-workspace")
}
fn kind(&self) -> &str {
"test-bound-authorization"
}
fn is_available(&self) -> bool {
true
}
fn execute(
&self,
request: WorkspaceRequest,
) -> Result<crate::WorkspaceResponse, crate::WorkspaceClientError> {
self.requests.lock().unwrap().push(request);
Ok(crate::WorkspaceResponse {
status: self.status,
body: if self.status == 200 {
r#"{"scope":null}"#.into()
} else {
r#"{"message":"test rejection"}"#.into()
},
})
}
}
#[test]
fn bound_subject_authorization_separates_denial_from_unavailable_without_identity_input() {
for status in [200, 403, 503] {
let client = Arc::new(BoundAuthorizationClient {
status,
requests: Mutex::new(vec![]),
});
let connection = SubjektivHostConnection::new(
Arc::new(BackendSubjektivHost {
client: client.clone(),
settings: SubjektivHostSettings {
language: "English".into(),
},
workspace_id: "test-workspace".into(),
consolidation_granted: false,
}),
SubjektivHostContext {
worker_id: "host-worker".into(),
session_id: "host-session".into(),
},
)
.unwrap();
let result = connection.authorize_operation("memory.resident.supply");
match status {
200 => assert!(result.is_ok()),
403 => assert!(matches!(
result,
Err(SubjektivHostError::PermissionDenied(_))
)),
503 => assert!(matches!(result, Err(SubjektivHostError::Unavailable(_)))),
_ => unreachable!(),
}
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 1);
assert_eq!(requests[0].method, WorkspaceRequestMethod::Post);
assert_eq!(
requests[0].path,
"/api/w/test-workspace/authorization/bound-check"
);
assert_eq!(
serde_json::from_str::<serde_json::Value>(requests[0].body.as_deref().unwrap())
.unwrap(),
serde_json::json!({
"operation": "memory.resident.supply",
"target": {"kind": "subject"},
})
);
drop(requests);
if status == 403 {
assert!(matches!(
connection.host.memory(
&connection.context,
SubjektivMemoryBackendOperation::ResidentContext(Default::default()),
),
Err(SubjektivHostError::PermissionDenied(_))
));
assert!(matches!(
connection.host.request_consolidation(
&connection.context,
MemoryConsolidateStagingOperation { force: false },
),
Err(SubjektivHostError::PermissionDenied(_))
));
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 3);
assert!(
requests.iter().all(|request| {
request.path == "/api/w/test-workspace/authorization/bound-check"
}),
"denied direct Host calls must not reach protected Memory reads or Job admission"
);
let operations: Vec<String> = requests
.iter()
.map(|request| {
serde_json::from_str::<serde_json::Value>(request.body.as_deref().unwrap())
.unwrap()["operation"]
.as_str()
.unwrap()
.to_owned()
})
.collect();
assert_eq!(
operations,
[
"memory.resident.supply",
"memory.resident.read",
"memory.consolidation.request"
]
);
}
}
}
#[derive(Clone)]
struct ScriptedClient(tokio::sync::mpsc::UnboundedSender<Request>);
#[async_trait]
@@ -920,6 +1140,7 @@ enabled = true
let read_store = store.clone();
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let host = Arc::new(LocalHost {
journal_operations: vec!["journal.recording.request"],
journal_observer: Some(Arc::new(move |context, reason| {
let session_id = context.session_id.parse().unwrap();
let logs = read_store
@@ -1010,6 +1231,7 @@ enabled = true
let reasons = Arc::new(Mutex::new(Vec::new()));
let observed = reasons.clone();
let host = Arc::new(LocalHost {
journal_operations: vec!["journal.recording.request"],
journal_observer: Some(Arc::new(move |_, reason| {
observed.lock().unwrap().push(reason);
})),
@@ -1120,6 +1342,7 @@ enabled = true
};
let host = Arc::new(LocalHost {
startup: Some(snapshot.clone()),
journal_operations: vec!["journal.startup.read", "journal.startup.supply"],
..LocalHost::default()
});
let store = CombinedStore::new(
@@ -1290,49 +1513,79 @@ enabled = true
}
#[tokio::test]
async fn direct_startup_read_error_rejects_exposure_without_initialized_head() {
async fn direct_startup_read_denial_supply_denial_and_unavailable_never_expose_a_head_or_provider()
{
use session_store::Store;
let temp = tempfile::tempdir().unwrap();
let _sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let mut manifest = WorkerManifest::from_toml(&format!("[worker]\nname='startup-error-{}'\n[model]\nscheme='anthropic'\nmodel_id='scripted'\n[engine]\n[scope]\nallow=[]", uuid::Uuid::now_v7())).unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = false;
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let host = Arc::new(LocalHost {
startup_error: true,
..LocalHost::default()
});
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let prepared = WorkerBootstrap::new(
manifest,
store.clone(),
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
},
)
.with_model_client(ScriptedClient(tx))
.with_subjektiv_host(host.clone())
.prepare()
.await
.unwrap();
let (session, segment) = (
prepared.worker().session_id(),
prepared.worker().segment_id(),
);
assert!(prepared.start().await.is_err());
assert!(!store.exists(session, segment).unwrap());
assert!(rx.try_recv().is_err());
assert_eq!(
host.startup_calls.load(std::sync::atomic::Ordering::SeqCst),
1
);
for (operations, unavailable, expected_reads, diagnostic) in [
(
vec!["journal.startup.read", "journal.startup.supply"],
true,
1,
"startup read failed",
),
(vec![], false, 0, "journal.startup.read"),
(
vec!["journal.startup.read"],
false,
1,
"journal.startup.supply",
),
] {
let temp = tempfile::tempdir().unwrap();
let _sandbox =
crate::runtime::worker_allocation::test_util::RuntimeDirSandbox::new(temp.path());
let mut manifest = WorkerManifest::from_toml(&format!("[worker]\nname='startup-error-{}'\n[model]\nscheme='anthropic'\nmodel_id='scripted'\n[engine]\n[scope]\nallow=[]", uuid::Uuid::now_v7())).unwrap();
manifest.feature.subjektiv.profile.enabled = true;
manifest.feature.subjektiv.profile.extraction.enabled = false;
let store = CombinedStore::new(
FsStore::new(temp.path().join("sessions")).unwrap(),
FsWorkerStore::new(temp.path().join("workers")).unwrap(),
);
let host = Arc::new(LocalHost {
startup: Some(JournalStartupSnapshot {
subject_id: "subject".into(),
journals: vec![],
omitted: true,
}),
startup_error: unavailable,
journal_operations: operations,
..LocalHost::default()
});
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
let prepared = WorkerBootstrap::new(
manifest,
store.clone(),
crate::PromptCatalogSource::builtins_only(),
WorkerWorkspaceContext::local_filesystem(None),
WorkerFilesystemAuthority::None,
WorkerBootstrapLayout::Direct {
runtime_base: temp.path().join("runtime"),
},
)
.with_model_client(ScriptedClient(tx))
.with_subjektiv_host(host.clone())
.prepare()
.await
.unwrap();
let (session, segment) = (
prepared.worker().session_id(),
prepared.worker().segment_id(),
);
let error = match prepared.start().await {
Ok(_) => panic!("unauthorized or unavailable Journal startup exposed a controller"),
Err(error) => error,
};
let crate::WorkerBootstrapError::Controller { source, .. } = error else {
panic!("expected startup failure, got {error:?}");
};
assert!(source.to_string().contains(diagnostic), "{source}");
assert!(!store.exists(session, segment).unwrap());
assert!(rx.try_recv().is_err());
assert_eq!(
host.startup_calls.load(std::sync::atomic::Ordering::SeqCst),
expected_reads
);
}
}
async fn launch_case(enabled: bool, attach: bool) {
+186 -68
View File
@@ -3053,6 +3053,7 @@ mod tests {
#[derive(Debug)]
struct ScriptedWorkspaceClient {
responses: Mutex<VecDeque<crate::worker::WorkspaceResponse>>,
authorizations: Mutex<VecDeque<Json>>,
requests: Mutex<Vec<crate::worker::WorkspaceRequest>>,
}
@@ -3060,10 +3061,20 @@ mod tests {
fn new(responses: impl IntoIterator<Item = crate::worker::WorkspaceResponse>) -> Self {
Self {
responses: Mutex::new(responses.into_iter().collect()),
authorizations: Mutex::new(VecDeque::new()),
requests: Mutex::new(Vec::new()),
}
}
fn with_authorizations(self, expected: impl IntoIterator<Item = Json>) -> Self {
*self.authorizations.lock().unwrap() = expected.into_iter().collect();
self
}
fn assert_authorizations_consumed(&self) {
assert!(self.authorizations.lock().unwrap().is_empty());
}
fn requests(&self) -> Vec<crate::worker::WorkspaceRequest> {
self.requests
.lock()
@@ -3089,6 +3100,19 @@ mod tests {
&self,
request: crate::worker::WorkspaceRequest,
) -> Result<crate::worker::WorkspaceResponse, crate::worker::WorkspaceClientError> {
if request.path == "/api/w/workspace/authorization/check" {
let actual: Json = serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
let expected = self.authorizations.lock().unwrap().pop_front();
assert_eq!(
Some(actual),
expected,
"unexpected operation/resource authorization"
);
return Ok(crate::worker::WorkspaceResponse {
status: 200,
body: json!({"scope": null}).to_string(),
});
}
self.requests
.lock()
.unwrap_or_else(|error| error.into_inner())
@@ -3105,6 +3129,14 @@ mod tests {
}
}
fn expected_authorization(operation: &str, kind: &str, id: Option<(&str, &str)>) -> Json {
let mut resource = json!({"kind": kind, "workspace_id": "workspace"});
if let Some((field, value)) = id {
resource[field] = json!(value);
}
json!({"operation": operation, "resource": resource})
}
fn objective_detail_response(content_digest: &str) -> crate::worker::WorkspaceResponse {
crate::worker::WorkspaceResponse {
status: 200,
@@ -6107,15 +6139,18 @@ mod tests {
status: 204,
body: String::new(),
};
let authoring_client = Arc::new(ScriptedWorkspaceClient::new([
query_response,
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"scope": null}).to_string(),
},
create_response,
comment_response,
]));
let authoring_client = Arc::new(
ScriptedWorkspaceClient::new([query_response, create_response, comment_response])
.with_authorizations([
expected_authorization("ticket.query", "workspace", None),
expected_authorization("ticket.create", "workspace", None),
expected_authorization(
"ticket.event.add",
"ticket",
Some(("ticket_id", "T-9")),
),
]),
);
let authoring_access =
crate::feature::builtin::ticket::TicketFeatureAccess::workspace_authoring();
let mut authoring_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
@@ -6194,38 +6229,64 @@ mod tests {
.await
.unwrap();
let authoring_requests = authoring_client.requests();
assert_eq!(authoring_requests.len(), 4);
authoring_client.assert_authorizations_consumed();
assert_eq!(authoring_requests.len(), 3);
assert!(authoring_requests[0].path.ends_with("/tickets/query"));
assert!(authoring_requests[1].path.ends_with("/authorization/check"));
assert!(authoring_requests[2].path.ends_with("/tickets"));
assert!(authoring_requests[1].path.ends_with("/tickets"));
assert!(
authoring_requests[3]
authoring_requests[2]
.path
.ends_with("/tickets/T-9/thread-events")
);
let workflow_client = Arc::new(ScriptedWorkspaceClient::new([
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001SOURCE", "resource_key": "T-9"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001TARGET", "resource_key": "T-10"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"ticket_id": "00001SOURCE",
"kind": "depends_on",
"target": "00001TARGET",
"note": null,
"author": "workspace",
"at": "2026-10-03T00:00:00Z"
})
.to_string(),
},
]));
let workflow_client = Arc::new(
ScriptedWorkspaceClient::new([
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001SOURCE", "resource_key": "T-9"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({"id": "00001TARGET", "resource_key": "T-10"}).to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"ticket_id": "00001SOURCE",
"kind": "depends_on",
"target": "00001TARGET",
"note": null,
"author": "workspace",
"at": "2026-10-03T00:00:00Z"
})
.to_string(),
},
])
.with_authorizations([
expected_authorization(
"ticket.relation.record",
"ticket",
Some(("ticket_id", "T-9")),
),
expected_authorization(
"ticket.relation.record",
"ticket",
Some(("ticket_id", "T-10")),
),
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-9"))),
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-10"))),
expected_authorization(
"ticket.relation.record",
"ticket",
Some(("ticket_id", "T-9")),
),
expected_authorization(
"ticket.relation.record",
"ticket",
Some(("ticket_id", "T-10")),
),
]),
);
let workflow_access = crate::feature::builtin::ticket::TicketFeatureAccess::workflow();
let mut workflow_engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
for definition in crate::feature::builtin::ticket::enabled_ticket_definitions(
@@ -6271,6 +6332,7 @@ mod tests {
let result = result.content.unwrap();
assert!(result.contains("T-9"));
assert!(result.contains("T-10"));
workflow_client.assert_authorizations_consumed();
let workflow_requests = workflow_client.requests();
assert_eq!(workflow_requests.len(), 3);
assert!(workflow_requests[0].path.ends_with("/tickets/T-9"));
@@ -6323,6 +6385,9 @@ mod tests {
})
.to_string(),
},
]).with_authorizations([
expected_authorization("merge_request.open", "ticket", Some(("ticket_id", "T-685"))),
expected_authorization("merge_request.read", "merge_request", Some(("merge_request_id", "MR-1")))
]));
let coder_config = MergeRequestFeatureConfig {
show: true,
@@ -6402,6 +6467,7 @@ mod tests {
)
.await
.unwrap();
coder_client.assert_authorizations_consumed();
let coder_requests = coder_client.requests();
assert_eq!(coder_requests.len(), 2);
assert_eq!(
@@ -6436,6 +6502,8 @@ mod tests {
})
.to_string(),
},
]).with_authorizations([
expected_authorization("merge_request.review", "merge_request", Some(("merge_request_id", "MR-1")))
]));
let reviewer_client: Arc<dyn crate::worker::WorkspaceClient> =
Arc::new(ReviewerChildWorkspaceClient::new(
@@ -6492,6 +6560,7 @@ mod tests {
)
.await
.unwrap();
reviewer_inner.assert_authorizations_consumed();
let reviewer_requests = reviewer_inner.requests();
assert_eq!(reviewer_requests.len(), 1);
assert_eq!(
@@ -6502,28 +6571,42 @@ mod tests {
serde_json::from_str(reviewer_requests[0].body.as_deref().unwrap()).unwrap();
assert_eq!(review_body["capability_token"], "review-capability-secret");
let orchestrator_client = Arc::new(ScriptedWorkspaceClient::new([
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"ready": true,
"blockers": [],
"subject_ref": "source-1",
"review": {"event_id": "review-1", "decision": "approve"}
})
.to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"event_id": "merge-1",
"approved_source_ref": "source-1",
"target_ref_before": "target-1",
"target_ref_after": "result-1"
})
.to_string(),
},
]));
let orchestrator_client = Arc::new(
ScriptedWorkspaceClient::new([
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"ready": true,
"blockers": [],
"subject_ref": "source-1",
"review": {"event_id": "review-1", "decision": "approve"}
})
.to_string(),
},
crate::worker::WorkspaceResponse {
status: 200,
body: json!({
"event_id": "merge-1",
"approved_source_ref": "source-1",
"target_ref_before": "target-1",
"target_ref_after": "result-1"
})
.to_string(),
},
])
.with_authorizations([
expected_authorization(
"merge_request.readiness",
"merge_request",
Some(("merge_request_id", "MR-1")),
),
expected_authorization(
"merge_request.complete",
"merge_request",
Some(("merge_request_id", "MR-1")),
),
]),
);
let orchestrator_config = MergeRequestFeatureConfig {
readiness_check: true,
complete: true,
@@ -6610,6 +6693,7 @@ mod tests {
rejected.to_string().contains("operation is not declared"),
"{rejected}"
);
orchestrator_client.assert_authorizations_consumed();
let orchestrator_requests = orchestrator_client.requests();
assert_eq!(orchestrator_requests.len(), 2);
assert_eq!(
@@ -6654,17 +6738,50 @@ mod tests {
status: 200,
body: json!({"resource_key": "T-7"}).to_string(),
};
let client = Arc::new(ScriptedWorkspaceClient::new([
query_response,
objective_detail_response("rev-1"),
objective_detail_response("rev-1"),
objective_detail_response("rev-2"),
objective_detail_response("rev-3"),
ticket_response(),
objective_detail_response("rev-4"),
ticket_response(),
objective_detail_response("rev-5"),
]));
let client = Arc::new(
ScriptedWorkspaceClient::new([
query_response,
objective_detail_response("rev-1"),
objective_detail_response("rev-1"),
objective_detail_response("rev-2"),
objective_detail_response("rev-3"),
ticket_response(),
objective_detail_response("rev-4"),
ticket_response(),
objective_detail_response("rev-5"),
])
.with_authorizations([
expected_authorization("objective.query", "workspace", None),
expected_authorization("objective.create", "workspace", None),
expected_authorization(
"objective.read",
"objective",
Some(("objective_id", "O-3")),
),
expected_authorization(
"objective.edit",
"objective",
Some(("objective_id", "O-3")),
),
expected_authorization(
"objective.set_state",
"objective",
Some(("objective_id", "O-3")),
),
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-7"))),
expected_authorization(
"objective.link_ticket",
"objective",
Some(("objective_id", "O-3")),
),
expected_authorization("ticket.read", "ticket", Some(("ticket_id", "T-7"))),
expected_authorization(
"objective.unlink_ticket",
"objective",
Some(("objective_id", "O-3")),
),
]),
);
let mut engine = Engine::<_, Mutable, ()>::new_annotated(DummyClient);
for definition in
crate::feature::builtin::objective::workspace_http_objective_tools(client.clone())
@@ -6764,6 +6881,7 @@ mod tests {
}
let requests = client.requests();
client.assert_authorizations_consumed();
assert_eq!(requests.len(), 9);
assert_eq!(
requests
File diff suppressed because it is too large Load Diff
+42 -4
View File
@@ -2898,6 +2898,22 @@ impl CompactionJournalHost {
}
impl worker::subjektiv::SubjektivHost for CompactionJournalHost {
fn authorize_operation(
&self,
_: &worker::subjektiv::SubjektivHostContext,
operation: &str,
) -> Result<(), worker::subjektiv::SubjektivHostError> {
// This fixture retains legacy Journal input even when fresh compaction
// reads are disabled. Supply permissions are independent of read capability.
match operation {
"journal.compaction.read" if self.granted => Ok(()),
"journal.startup.supply" | "journal.compaction.supply" => Ok(()),
_ => Err(worker::subjektiv::SubjektivHostError::PermissionDenied(
format!("compaction fixture did not grant {operation}"),
)),
}
}
fn settings(&self) -> worker::subjektiv::SubjektivHostSettings {
worker::subjektiv::SubjektivHostSettings {
language: "English".into(),
@@ -3479,7 +3495,12 @@ async fn journal_three_compactions_freeze_one_snapshot_preserve_full_summary_tai
#[tokio::test]
async fn journal_empty_disabled_and_available_compaction_preserve_inputs_without_failure_warnings()
{
assert_journal_compaction_cases(&["absent", "available", "disabled", "no-host"]).await;
assert_journal_compaction_cases(&["absent", "available", "disabled"]).await;
}
#[tokio::test]
async fn journal_retained_input_without_host_refuses_compaction_before_provider_or_rewrite() {
assert_journal_compaction_cases(&["no-host"]).await;
}
#[tokio::test]
@@ -3543,12 +3564,29 @@ async fn assert_journal_compaction_cases(cases: &[&'static str]) {
// This observes the real Feature warning while compacting into FsStore.
let logs = diagnostics::CapturedWarnings::default();
use tracing::instrument::WithSubscriber;
fixture
let before_segment = fixture.worker.segment_id();
let before_requests = observed.requests().len();
let result = fixture
.worker
.compact(0)
.with_subscriber(logs.subscriber())
.await
.unwrap();
.await;
if case == "no-host" {
assert!(
result
.unwrap_err()
.to_string()
.contains("retained protected input journal requires its matching source")
);
assert_eq!(observed.requests().len(), before_requests);
assert_eq!(fixture.worker.segment_id(), before_segment);
assert_eq!(
journal_body(&fixture.worker),
"PREVIOUS_JOURNAL_DELEGATED_CONTEXT"
);
continue;
}
result.unwrap();
let log = logs.text();
let failure = match case {
"unavailable" => Some(("snapshot", "host_unavailable")),
+149 -6
View File
@@ -221,8 +221,10 @@ async fn make_worker_with_pwd(
make_worker_with_pwd_and_manifest(client, MANIFEST_TOML).await
}
#[derive(Debug)]
struct AvailableWorkspaceClient;
#[derive(Debug, Default)]
struct AvailableWorkspaceClient {
consolidation_input_granted: bool,
}
impl WorkspaceClient for AvailableWorkspaceClient {
fn workspace_id(&self) -> Option<&str> {
@@ -247,6 +249,28 @@ impl WorkspaceClient for AvailableWorkspaceClient {
body: "{}".into(),
});
}
if request.path == "/api/w/workspace-1/authorization/bound-check" {
let input: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.operation, "memory.consolidation.supply");
assert_eq!(
input.target,
server_api::authorization::BoundAuthorizationTarget::Subject {}
);
return Ok(WorkspaceResponse {
status: if self.consolidation_input_granted {
200
} else {
403
},
body: serde_json::json!({"scope": {
"kind": "subject",
"workspace_id": "workspace-1",
"subject_id": "consolidation-subject"
}})
.to_string(),
});
}
Err(WorkspaceClientError::Unavailable("test".to_string()))
}
}
@@ -1101,7 +1125,10 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
resolved.manifest,
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
WorkerWorkspaceContext::with_client(
None,
Arc::new(AvailableWorkspaceClient::default()),
),
)
.await;
if bound {
@@ -1137,6 +1164,7 @@ async fn backend_job_capability_is_explicit_and_preserves_profile_features() {
#[derive(Debug, Default)]
struct JournalWorkspaceClient {
journal_input_granted: bool,
requests: Mutex<Vec<WorkspaceRequest>>,
result_calls: AtomicUsize,
}
@@ -1160,6 +1188,24 @@ impl WorkspaceClient for JournalWorkspaceClient {
body: "{}".into(),
});
}
if request.path == "/api/w/workspace-test/authorization/bound-check" {
let input: server_api::authorization::BoundOperationAuthorizationRequest =
serde_json::from_str(request.body.as_deref().unwrap()).unwrap();
assert_eq!(input.operation, "journal.job.input.supply");
assert_eq!(
input.target,
server_api::authorization::BoundAuthorizationTarget::Subject {}
);
return Ok(WorkspaceResponse {
status: if self.journal_input_granted { 200 } else { 403 },
body: serde_json::json!({"scope": {
"kind": "subject",
"workspace_id": "workspace-test",
"subject_id": "journal-subject"
}})
.to_string(),
});
}
self.requests.lock().unwrap().push(request.clone());
let (status, body) = if request.path.ends_with("/subjektiv/journal/job/finish") {
(
@@ -1248,7 +1294,10 @@ async fn journal_backend_grant_installs_bound_tools_and_seals_saved_finish_befor
};
let client = MockClient::sequential(responses);
let captured = client.clone();
let backend = Arc::new(JournalWorkspaceClient::default());
let backend = Arc::new(JournalWorkspaceClient {
journal_input_granted: granted,
..Default::default()
});
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
manifest,
@@ -1381,7 +1430,12 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
manifest,
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
WorkerWorkspaceContext::with_client(
None,
Arc::new(AvailableWorkspaceClient {
consolidation_input_granted: true,
}),
),
)
.await;
worker
@@ -1420,6 +1474,95 @@ async fn consolidation_job_preserves_custom_instruction_and_explicit_tool_policy
}
}
#[tokio::test]
async fn denied_subject_job_input_supply_stops_before_model_dispatch() {
for (profile, consolidation, operation) in [
(
"subjektiv-memory-consolidation",
true,
"memory.consolidation.supply",
),
(
"subjektiv-journal-recording",
false,
"journal.job.input.supply",
),
] {
let workspace = tempfile::tempdir().unwrap();
let mut manifest = ProfileResolver::new()
.with_workspace_base(workspace.path())
.resolve(
&ProfileSelector::source_named(ProfileRegistrySource::Builtin, profile),
ProfileResolveOptions::with_worker_name("denied-subject-job"),
)
.unwrap()
.manifest;
if consolidation {
manifest
.feature
.subjektiv
.bind_workspace_settings(manifest::WorkspaceMemorySettingsSnapshot {
workspace_id: "workspace-1".into(),
language: "English".into(),
})
.unwrap();
}
let journal = Arc::new(JournalWorkspaceClient::default());
let backend: Arc<dyn WorkspaceClient> = if consolidation {
Arc::new(AvailableWorkspaceClient::default())
} else {
journal.clone()
};
let client = MockClient::new(simple_text_events());
let captured = client.clone();
let (mut worker, _pwd) = make_worker_with_manifest_and_workspace_context(
client,
manifest,
WorkerWorkspaceContext::with_client(None, backend),
)
.await;
worker
.bind_backend_job(worker::BackendJobExecutionBinding {
job_id: "denied-job".into(),
attempt_id: "attempt".into(),
input_digest: Some("digest".into()),
subjektiv_consolidation: consolidation,
subjektiv_journal: !consolidation,
})
.unwrap();
let handle = spawn_controller(worker).await;
let mut events = handle.subscribe();
let submission_request_id = protocol::new_submission_request_id();
handle
.send(Method::submit_text(
submission_request_id.clone(),
"Inspect protected Job input.",
))
.await
.unwrap();
let mut denial = None;
assert!(
drain_until(&mut events, std::time::Duration::from_secs(2), |event| {
if let Event::Error { message, .. } = event {
denial = Some(message.clone());
}
matches!(event, Event::WorkerState { snapshot }
if snapshot.catalog_status() == WorkerStatus::Idle
&& snapshot.last_finished_submission_request_id.as_deref()
== Some(submission_request_id.as_str()))
})
.await,
"denied {operation} Submit did not finish"
);
let denial = denial.expect("protected Job input denial must be reported");
assert!(denial.contains(operation), "{denial}");
assert!(denial.contains("403"), "{denial}");
assert!(captured.captured_requests().is_empty());
assert!(journal.requests.lock().unwrap().is_empty());
assert_eq!(journal.result_calls.load(Ordering::SeqCst), 0);
}
}
#[tokio::test]
async fn enabled_task_and_web_features_register_their_tools() {
let manifest = r#"
@@ -1759,7 +1902,7 @@ permission = "write"
let (worker, _pwd) = make_worker_with_pwd_manifest_and_workspace_context(
client,
manifest_toml,
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient)),
WorkerWorkspaceContext::with_client(None, Arc::new(AvailableWorkspaceClient::default())),
)
.await;
let handle = spawn_controller(worker).await;