fix: retain structured Workdir refusal diagnostics across provider boundaries

This commit is contained in:
2026-10-09 22:23:22 +09:00
parent e8828cd1b5
commit f9d70e2ae2
28 changed files with 2053 additions and 198 deletions
+2 -8
View File
@@ -66,10 +66,7 @@ impl<'a> CheckedTarget<'a> {
} else {
policy.is_readable_paths(logical, logical)
}) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"checkout target denied",
));
return Err(crate::FsDenialReason::CheckoutTargetDenied.into_io_error());
}
let parent = if relative.as_os_str().is_empty() {
None
@@ -294,10 +291,7 @@ impl FsAccessPolicy for CheckedCreate<'_, '_> {
if components.peek().is_some()
&& !self.target.policy.is_writable_paths(&parent, &parent)
{
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"Create parent denied",
));
return Err(crate::FsDenialReason::CheckoutCreateParentDenied.into_io_error());
}
}
// Nested mkdir may have effects before failure; never auto-retry.
+27 -22
View File
@@ -37,6 +37,26 @@ pub const MAX_RESULT_PATH_BYTES: usize = 1024 * 1024;
/// Provider-side ceiling for aggregate grep source bytes per operation.
pub const MAX_GREP_SOURCE_BYTES: u64 = 64 * 1024 * 1024;
/// Safe origin for a synthetic `PermissionDenied` from this crate.
/// Contains no paths or provider-authored text; real OS errors are not wrapped.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Error)]
pub enum FsDenialReason {
#[error("path is outside provider root")]
ProviderRootExceeded,
#[error("symbolic links are not permitted by this provider")]
ProviderSymlinkDenied,
#[error("checkout target denied")]
CheckoutTargetDenied,
#[error("Create parent denied")]
CheckoutCreateParentDenied,
}
impl FsDenialReason {
pub fn into_io_error(self) -> std::io::Error {
std::io::Error::new(std::io::ErrorKind::PermissionDenied, self)
}
}
/// Open `path` beneath `root` without following any symbolic link. Linux uses
/// `openat2` so resolution and open are one kernel-enforced operation. Other
/// platforms fail closed rather than silently weakening an External grant.
@@ -91,10 +111,7 @@ pub fn open_beneath_no_symlinks_at(
const RESOLVE_BENEATH: u64 = 0x08;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
}
let relative = if relative.as_os_str().is_empty() {
CString::new(".").expect("static path")
@@ -122,10 +139,7 @@ pub fn open_beneath_no_symlinks_at(
if fd < 0 {
let error = std::io::Error::last_os_error();
if error.raw_os_error() == Some(libc::ELOOP) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"symbolic links are not permitted by this provider",
));
return Err(crate::FsDenialReason::ProviderSymlinkDenied.into_io_error());
}
return Err(error);
}
@@ -146,12 +160,9 @@ pub fn open_beneath_no_symlinks_at(
#[cfg(target_os = "linux")]
pub fn open_beneath_no_symlinks(root: &Path, path: &Path) -> std::io::Result<std::fs::File> {
let relative = path.strip_prefix(root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
)
})?;
let relative = path
.strip_prefix(root)
.map_err(|_| crate::FsDenialReason::ProviderRootExceeded.into_io_error())?;
let root = open_root_no_symlinks(root)?;
open_beneath_no_symlinks_at(&root, relative)
}
@@ -184,20 +195,14 @@ pub fn atomic_write_beneath_no_symlinks_at(
use std::os::unix::ffi::OsStrExt;
if relative.is_absolute() {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
}
let mut components = relative.components().peekable();
let mut parent = root.try_clone()?;
let mut file_name = None::<OsString>;
while let Some(component) = components.next() {
let std::path::Component::Normal(name) = component else {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
"path is outside provider root",
));
return Err(crate::FsDenialReason::ProviderRootExceeded.into_io_error());
};
if components.peek().is_none() {
file_name = Some(name.to_os_string());
+53
View File
@@ -98,6 +98,24 @@ pub struct WorkdirOperationApiError {
pub diagnostics: Option<Vec<Diagnostic>>,
#[serde(skip, default = "default_repository_error_status")]
status: u16,
/// Server-local metadata retained by generated error response extensions, never the wire body.
#[serde(skip)]
pub workdir_log: Option<WorkdirOperationLogContext>,
#[serde(skip)]
pub denial_reason: Option<workdir::WorkdirDenialReason>,
}
/// Internal operational correlation. Producers supply fixed-length ID fingerprints and
/// an allowlisted operation label, not request text, paths, commands or credentials.
#[derive(Clone, Debug, Default)]
pub struct WorkdirOperationLogContext {
pub operation: &'static str,
pub stage: &'static str,
pub workspace_id_hash: String,
pub runtime_id_hash: Option<String>,
pub worker_id_hash: Option<String>,
pub workdir_id_hash: Option<String>,
pub attachment_alias_hash: String,
}
impl WorkdirOperationApiError {
@@ -108,6 +126,8 @@ impl WorkdirOperationApiError {
message: error.message,
diagnostics: Some(error.diagnostics),
status: error.status,
workdir_log: None,
denial_reason: None,
}
}
@@ -119,6 +139,8 @@ impl WorkdirOperationApiError {
message: error.message,
diagnostics: None,
status,
workdir_log: None,
denial_reason: error.denial_reason,
}
}
}
@@ -13984,3 +14006,34 @@ mod typescript_tests {
.collect()
}
}
#[cfg(test)]
mod workdir_error_diagnostic_tests {
use super::*;
#[test]
fn workdir_internal_diagnostics_never_enter_public_error_body_or_schema() {
let mut error = WorkdirOperationApiError::provider(workdir::http::WorkdirTransportError {
code: workdir::http::WorkdirTransportErrorCode::Denied,
message: "Workdir operation was denied".to_string(),
denial_reason: Some(workdir::WorkdirDenialReason::OsPermissionDenied),
});
error.workdir_log = Some(WorkdirOperationLogContext {
operation: "write",
workspace_id_hash: "internal-workspace-fingerprint".to_string(),
..Default::default()
});
let wire = serde_json::to_value(&error).unwrap();
assert_eq!(
wire,
serde_json::json!({"code": "denied", "message": "Workdir operation was denied"})
);
assert_eq!(api_macros::HttpError::status_code(&error), 403);
let decoded: WorkdirOperationApiError = serde_json::from_value(wire).unwrap();
assert!(decoded.denial_reason.is_none());
assert!(decoded.workdir_log.is_none());
let schema = serde_json::to_value(schemars::schema_for!(WorkdirOperationApiError)).unwrap();
assert!(schema["properties"].get("workdir_log").is_none());
assert!(schema["properties"].get("denial_reason").is_none());
}
}
+7
View File
@@ -421,6 +421,13 @@ fn checkout_known_refusals_have_typed_codes_without_changing_normal_tools() {
use workdir::{WorkdirError, WorkdirSessionCapability};
let cases = [
(WorkdirError::Denied("denied".into()), "checkout_denied"),
(
WorkdirError::DenialContext {
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
source: Box::new(WorkdirError::OutOfScope("outside".into())),
},
"checkout_denied",
),
(
WorkdirError::OutOfScope("outside".into()),
"checkout_denied",
+5
View File
@@ -139,7 +139,12 @@ impl FileTarget {
pub(crate) fn checked_error(error: workdir::WorkdirError) -> ToolError {
use workdir::WorkdirError;
if let WorkdirError::DenialContext { source, .. } = error {
// Diagnostic enrichment must not change the established tool classification.
return checked_error(*source);
}
let code = match &error {
WorkdirError::DenialContext { .. } => unreachable!("unwrapped above"),
WorkdirError::Conflict(_) | WorkdirError::NotFound(_) => "checkout_stale",
WorkdirError::Denied { .. }
| WorkdirError::OutOfScope { .. }
+3 -2
View File
@@ -148,8 +148,9 @@ impl CheckoutSearchRequest {
if !std::path::Path::new(self.operation.path().as_str())
.starts_with(self.output_root.as_str())
{
return Err(WorkdirError::Denied(
"checkout search must stay beneath its output root".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
"checkout search must stay beneath its output root",
));
}
if self.scope_layers.len() > 16
+9 -1
View File
@@ -751,7 +751,15 @@ async fn checkout_search_native_skips_symlinked_ignore_sources_without_outside_e
)))
.await
.unwrap_err();
assert!(matches!(error, WorkdirError::OutOfScope(_)));
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
}
for (relative, hidden) in [
(".ignore", "root-ignore-visible.txt"),
+44 -3
View File
@@ -497,18 +497,24 @@ impl<'de> Deserialize<'de> for ExternalWorkdirOperationResult {
#[serde(deny_unknown_fields)]
pub struct ExternalWorkdirOperationError {
code: crate::http::WorkdirTransportErrorCode,
#[serde(default, skip_serializing_if = "Option::is_none")]
denial_reason: Option<crate::WorkdirDenialReason>,
}
impl ExternalWorkdirOperationError {
/// Collapse provider failures to a closed error code. Provider-authored
/// diagnostics never cross the External Workdir trust boundary.
/// Keep only closed error codes and typed refusal reasons. Provider-authored
/// text never crosses the External Workdir trust boundary.
pub fn from_transport_error(error: WorkdirTransportError) -> Self {
Self { code: error.code }
Self {
code: error.code,
denial_reason: error.denial_reason,
}
}
pub fn into_transport_error(self) -> WorkdirTransportError {
WorkdirTransportError {
code: self.code,
denial_reason: self.denial_reason,
message: format!("External Workdir provider reported {}", self.code.as_str()),
}
}
@@ -719,10 +725,45 @@ mod tests {
}
}
#[test]
fn external_error_wire_forwards_allowlisted_reasons_without_provider_text() {
use crate::WorkdirDenialReason as Reason;
use crate::http::WorkdirTransportErrorCode as Code;
for reason in [
Some(Reason::ExternalScopeSymlink),
Some(Reason::OsPermissionDenied),
None,
] {
let external =
ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: Code::Denied,
message: "/home/operator/private provider-secret".repeat(4096),
denial_reason: reason,
});
let wire = serde_json::to_string(&external).unwrap();
assert!(wire.len() < 128);
assert_no_provider_authority(&serde_json::from_str(&wire).unwrap());
let decoded: ExternalWorkdirOperationError = serde_json::from_str(&wire).unwrap();
let error = decoded.into_transport_error().into_workdir_error();
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(forwarded.code, Code::Denied);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.message, "Workdir operation was denied");
}
let old: ExternalWorkdirOperationError =
serde_json::from_str(r#"{"code":"denied"}"#).unwrap();
assert_eq!(old.into_transport_error().denial_reason, None);
for reason in ["/home/operator/private", &"provider-secret".repeat(4096)] {
let input = serde_json::json!({"code":"denied", "denial_reason":reason});
assert!(serde_json::from_value::<ExternalWorkdirOperationError>(input).is_err());
}
}
#[test]
fn provider_failures_are_closed_and_drop_provider_authored_diagnostics() {
let error = ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
code: crate::http::WorkdirTransportErrorCode::Internal,
denial_reason: None,
message: "/home/operator/private provider-secret\ncontrol".to_string(),
});
let frame = ExternalWorkdirProviderFrame::current(
+31 -3
View File
@@ -357,9 +357,17 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
)
.await
.unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!(
matches!(
error,
classification,
WorkdirError::SymlinkOutOfScope { .. } | WorkdirError::OutOfScope(_)
),
"unexpected error for {path}: {error:?}"
@@ -375,6 +383,10 @@ async fn external_local_provider_rejects_symlink_roots_and_traversal() {
BoundedReadLimits::new(4096, 1024).unwrap(),
)
.unwrap_err();
assert_eq!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::ExternalRootSymlink)
);
assert!(matches!(error, WorkdirError::Denied(_)));
}
@@ -408,9 +420,17 @@ async fn external_read_write_provider_rejects_symlink_mutation_escape() {
)
.await
.unwrap_err();
let classification = match &error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert_ne!(
error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
assert!(
matches!(
error,
classification,
WorkdirError::SymlinkOutOfScope { .. }
| WorkdirError::OutOfScope(_)
| WorkdirError::Denied(_)
@@ -559,7 +579,15 @@ async fn external_grep_honors_gitignore_above_a_nested_search_root() {
)
.await
.unwrap_err();
assert!(matches!(symlink_error, WorkdirError::OutOfScope(_)));
let classification = match &symlink_error {
WorkdirError::DenialContext { source, .. } => source.as_ref(),
error => error,
};
assert!(matches!(classification, WorkdirError::OutOfScope(_)));
assert_ne!(
symlink_error.denial_reason(),
Some(crate::WorkdirDenialReason::OsPermissionDenied)
);
}
#[cfg(target_os = "linux")]
+28 -2
View File
@@ -269,6 +269,9 @@ impl WorkdirTransportErrorCode {
pub struct WorkdirTransportError {
pub code: WorkdirTransportErrorCode,
pub message: String,
/// Internal provider diagnostics. Public response boundaries may omit this.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub denial_reason: Option<crate::WorkdirDenialReason>,
}
impl WorkdirTransportError {
@@ -276,6 +279,11 @@ impl WorkdirTransportError {
pub fn from_workdir_error(error: &WorkdirError) -> Self {
use WorkdirTransportErrorCode as Code;
let (code, message) = match error {
WorkdirError::DenialContext { reason, source } => {
let mut transport = Self::from_workdir_error(source);
transport.denial_reason = Some(*reason);
return transport;
}
WorkdirError::OutcomeUnknown(_) => (
Code::OutcomeUnknown,
"Workdir operation may have effects; inspect before retrying",
@@ -289,6 +297,7 @@ impl WorkdirTransportError {
return Self {
code: Code::Unsupported,
message: format!("Workdir capability {capability:?} is not available"),
denial_reason: None,
};
}
WorkdirError::UnsupportedOperation(_) => {
@@ -346,12 +355,13 @@ impl WorkdirTransportError {
Self {
code,
message: message.to_string(),
denial_reason: error.denial_reason(),
}
}
pub fn into_workdir_error(self) -> WorkdirError {
use WorkdirTransportErrorCode as Code;
match self.code {
let error = match self.code {
Code::NotFound => WorkdirError::NotFound("<remote>".into()),
Code::Conflict => WorkdirError::Conflict(self.message),
Code::OutcomeUnknown => WorkdirError::OutcomeUnknown(
@@ -359,7 +369,11 @@ impl WorkdirTransportError {
),
Code::Unsupported => WorkdirError::UnsupportedOperation(self.message),
Code::InvalidRequest => WorkdirError::InvalidArgument(self.message),
Code::Denied => WorkdirError::Denied(self.message),
Code::Denied => WorkdirError::Denied(crate::WorkdirDenial {
reason: self.denial_reason,
// Provider-authored text is not trusted diagnostic data.
message: "Workdir operation was denied".into(),
}),
Code::OutOfScope => WorkdirError::OutOfScope("<remote>".into()),
Code::SymlinkOutOfScope => WorkdirError::SymlinkOutOfScope {
path: "<remote>".into(),
@@ -390,7 +404,18 @@ impl WorkdirTransportError {
},
Code::Transport => WorkdirError::Transport(self.message),
Code::Internal => WorkdirError::OperationFailed,
};
// Preserve any supplied reason independently of the public classification.
// Without a reason, retain the existing classification's deliberate fallback.
if let Some(reason) = self.denial_reason {
if error.denial_reason() != Some(reason) {
return WorkdirError::DenialContext {
reason,
source: Box::new(error),
};
}
}
error
}
}
@@ -949,6 +974,7 @@ mod tests {
] {
let transport = WorkdirTransportError {
code,
denial_reason: None,
message: if code == WorkdirTransportErrorCode::Conflict {
"The target file's content or existence changed since it was last observed; read the file again before retrying"
.to_string()
+143 -3
View File
@@ -211,7 +211,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
}) {
Ok(())
} else {
Err(WorkdirError::Denied(
Err(WorkdirError::denied(
WorkdirDenialReason::ScopeResolutionUnavailable,
"Workdir provider cannot establish resolved scope authority".to_string(),
))
}
@@ -221,7 +222,8 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
&self,
_request: WorkdirScopeOverlapRequest,
) -> Result<bool, WorkdirError> {
Err(WorkdirError::Denied(
Err(WorkdirError::denied(
WorkdirDenialReason::ScopeComparisonUnavailable,
"Workdir provider cannot compare resolved scope authority".to_string(),
))
}
@@ -292,10 +294,125 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync {
pub type WorkdirSessionHandle = Arc<dyn WorkdirSession>;
/// Closed diagnostic vocabulary: no request text, paths or secrets.
/// A reason describes a refusing branch, not an authorization grant.
#[derive(
Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize, schemars::JsonSchema,
)]
#[serde(rename_all = "snake_case")]
pub enum WorkdirDenialReason {
ScopeResolutionUnavailable,
ScopeComparisonUnavailable,
ScopedCapabilityDenied,
LogicalScopeExceeded,
InvalidScopedPath,
ChildWriteLeaseConflict,
EmptyScope,
ParentCapabilityDenied,
CommandRequiresWritableScope,
ParentScopeExceeded,
CwdOutsideReadableScope,
ReadOnlySession,
ExternalRootSymlink,
ExternalRootChanged,
ExternalRootMoved,
ExternalScopeSymlink,
AttachmentScopeExceeded,
DelegatedScopeExceeded,
CheckoutOutputRootExceeded,
ProviderRootExceeded,
ProviderSymlinkDenied,
CheckoutTargetDenied,
CheckoutCreateParentDenied,
CheckoutSearchPathDenied,
CheckoutEnumerationDenied,
PathOutOfScope,
SymlinkTargetOutOfScope,
PathReadOnly,
OsPermissionDenied,
/// PermissionDenied without a typed provider origin or an OS error code.
/// In particular, do not mislabel lower-layer synthetic refusals as OS errors.
UnclassifiedPermissionDenied,
}
impl WorkdirDenialReason {
/// Stable allowlisted label for internal diagnostics, matching the wire value.
pub const fn as_str(self) -> &'static str {
match self {
Self::ScopeResolutionUnavailable => "scope_resolution_unavailable",
Self::ScopeComparisonUnavailable => "scope_comparison_unavailable",
Self::ScopedCapabilityDenied => "scoped_capability_denied",
Self::LogicalScopeExceeded => "logical_scope_exceeded",
Self::InvalidScopedPath => "invalid_scoped_path",
Self::ChildWriteLeaseConflict => "child_write_lease_conflict",
Self::EmptyScope => "empty_scope",
Self::ParentCapabilityDenied => "parent_capability_denied",
Self::CommandRequiresWritableScope => "command_requires_writable_scope",
Self::ParentScopeExceeded => "parent_scope_exceeded",
Self::CwdOutsideReadableScope => "cwd_outside_readable_scope",
Self::ReadOnlySession => "read_only_session",
Self::ExternalRootSymlink => "external_root_symlink",
Self::ExternalRootChanged => "external_root_changed",
Self::ExternalRootMoved => "external_root_moved",
Self::ExternalScopeSymlink => "external_scope_symlink",
Self::AttachmentScopeExceeded => "attachment_scope_exceeded",
Self::DelegatedScopeExceeded => "delegated_scope_exceeded",
Self::CheckoutOutputRootExceeded => "checkout_output_root_exceeded",
Self::ProviderRootExceeded => "provider_root_exceeded",
Self::ProviderSymlinkDenied => "provider_symlink_denied",
Self::CheckoutTargetDenied => "checkout_target_denied",
Self::CheckoutCreateParentDenied => "checkout_create_parent_denied",
Self::CheckoutSearchPathDenied => "checkout_search_path_denied",
Self::CheckoutEnumerationDenied => "checkout_enumeration_denied",
Self::PathOutOfScope => "path_out_of_scope",
Self::SymlinkTargetOutOfScope => "symlink_target_out_of_scope",
Self::PathReadOnly => "path_read_only",
Self::OsPermissionDenied => "os_permission_denied",
Self::UnclassifiedPermissionDenied => "unclassified_permission_denied",
}
}
}
/// Original local messages remain available, but are not diagnostic wire data.
/// Missing reasons from older providers remain explicitly unknown.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WorkdirDenial {
pub reason: Option<WorkdirDenialReason>,
pub message: String,
}
impl std::fmt::Display for WorkdirDenial {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.message)
}
}
impl From<String> for WorkdirDenial {
fn from(message: String) -> Self {
Self {
reason: None,
message,
}
}
}
impl From<&str> for WorkdirDenial {
fn from(message: &str) -> Self {
message.to_string().into()
}
}
#[derive(Debug, thiserror::Error)]
pub enum WorkdirError {
#[error("Workdir operation denied: {0}")]
Denied(String),
Denied(WorkdirDenial),
/// Retain the source's existing classification and display while attaching
/// a reason (including checkout's historical PermissionDenied mapping).
#[error("{source}")]
DenialContext {
reason: WorkdirDenialReason,
#[source]
source: Box<WorkdirError>,
},
#[error("Workdir session is closed")]
SessionClosed,
@@ -398,6 +515,29 @@ pub enum WorkdirError {
}
impl WorkdirError {
pub fn denied(reason: WorkdirDenialReason, message: impl Into<String>) -> Self {
Self::Denied(WorkdirDenial {
reason: Some(reason),
message: message.into(),
})
}
/// Safe diagnostic data only; never infer a reason from arbitrary messages.
pub fn denial_reason(&self) -> Option<WorkdirDenialReason> {
use WorkdirDenialReason as Reason;
match self {
Self::Denied(denial) => denial.reason,
Self::DenialContext { reason, .. } => Some(*reason),
Self::OutOfScope(_) => Some(Reason::PathOutOfScope),
Self::SymlinkOutOfScope { .. } => Some(Reason::SymlinkTargetOutOfScope),
Self::ReadOnly(_) => Some(Reason::PathReadOnly),
Self::Io { source, .. } if source.kind() == std::io::ErrorKind::PermissionDenied => {
Some(local::io_denial_reason(source))
}
_ => None,
}
}
pub(crate) fn io(path: &Path, source: std::io::Error) -> Self {
Self::Io {
path: path.to_path_buf(),
+139 -24
View File
@@ -213,6 +213,51 @@ struct ScopeAccess {
operation_guard: Option<OperationGuard>,
}
/// Policy refusals carried through FsAccessPolicy's I/O interface are not OS
/// PermissionDenied. This marker contains only fixed text and a closed reason.
#[derive(Debug, thiserror::Error)]
#[error("{message}")]
struct PolicyPermissionDenied {
reason: crate::WorkdirDenialReason,
message: &'static str,
}
fn policy_permission_denied(
reason: crate::WorkdirDenialReason,
message: &'static str,
) -> std::io::Error {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
PolicyPermissionDenied { reason, message },
)
}
pub(crate) fn io_denial_reason(error: &std::io::Error) -> crate::WorkdirDenialReason {
use crate::WorkdirDenialReason as Reason;
if let Some(source) = error.get_ref() {
if let Some(denial) = source.downcast_ref::<PolicyPermissionDenied>() {
return denial.reason;
}
if let Some(denial) = source.downcast_ref::<fs_operation::FsDenialReason>() {
return match denial {
fs_operation::FsDenialReason::ProviderRootExceeded => Reason::ProviderRootExceeded,
fs_operation::FsDenialReason::ProviderSymlinkDenied => {
Reason::ProviderSymlinkDenied
}
fs_operation::FsDenialReason::CheckoutTargetDenied => Reason::CheckoutTargetDenied,
fs_operation::FsDenialReason::CheckoutCreateParentDenied => {
Reason::CheckoutCreateParentDenied
}
};
}
}
if error.raw_os_error().is_some() {
Reason::OsPermissionDenied
} else {
Reason::UnclassifiedPermissionDenied
}
}
impl ScopeAccess {
fn new(
scope: Arc<Scope>,
@@ -238,8 +283,8 @@ impl ScopeAccess {
)
})?;
let relative = path.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
)
})?;
@@ -288,8 +333,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
if logical.starts_with(&self.root) {
Ok(logical.to_path_buf())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
Err(policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
))
}
@@ -394,8 +439,8 @@ impl fs_operation::FsAccessPolicy for ScopeAccess {
)
})?;
let relative = resolved.strip_prefix(&self.root).map_err(|_| {
std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
policy_permission_denied(
crate::WorkdirDenialReason::ProviderRootExceeded,
"path is outside provider root",
)
})?;
@@ -491,8 +536,8 @@ impl SearchAccess {
if self.read_allowed(logical, resolved) {
Ok(())
} else {
Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
Err(policy_permission_denied(
crate::WorkdirDenialReason::CheckoutSearchPathDenied,
"checkout search path denied",
))
}
@@ -545,8 +590,8 @@ impl fs_operation::FsAccessPolicy for SearchAccess {
}
fn open_read_dir(&self, logical: &Path, resolved: &Path) -> std::io::Result<std::fs::ReadDir> {
if !self.enumeration_allowed(logical, resolved) {
return Err(std::io::Error::new(
std::io::ErrorKind::PermissionDenied,
return Err(policy_permission_denied(
crate::WorkdirDenialReason::CheckoutEnumerationDenied,
"checkout directory enumeration denied",
));
}
@@ -648,7 +693,8 @@ impl ExternalWorkdirRoot {
let selected_metadata = std::fs::symlink_metadata(directory)
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if selected_metadata.file_type().is_symlink() {
return Err(WorkdirError::Denied(
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootSymlink,
"External Workdir root must not be a symbolic link".to_string(),
));
}
@@ -678,7 +724,8 @@ impl ExternalWorkdirRoot {
if (selected_metadata.dev(), selected_metadata.ino())
!= (opened_metadata.dev(), opened_metadata.ino())
{
return Err(WorkdirError::Denied(
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootChanged,
"External Workdir root changed while it was being pinned".to_string(),
));
}
@@ -691,7 +738,8 @@ impl ExternalWorkdirRoot {
let path = std::fs::read_link(format!("/proc/self/fd/{}", handle.as_raw_fd()))
.map_err(|error| WorkdirError::io(Path::new("<external-root>"), error))?;
if !path.is_absolute() || path.to_string_lossy().ends_with(" (deleted)") {
return Err(WorkdirError::Denied(
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalRootMoved,
"External Workdir root moved while it was being pinned".to_string(),
));
}
@@ -1334,7 +1382,8 @@ impl WorkdirSession for LocalWorkdirSession {
self.validate_operation_path(&request.path)?;
let logical = self.inner.root.join(request.path.as_str());
if self.inner.reject_symlinks && fs_operation::first_symlink(&logical).is_some() {
return Err(WorkdirError::Denied(
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ExternalScopeSymlink,
"External Workdir scope cannot traverse symbolic links".to_string(),
));
}
@@ -1353,10 +1402,13 @@ impl WorkdirSession for LocalWorkdirSession {
WorkdirToolScopePermission::Write => parent_permission == Some(Permission::Write),
};
if !parent_allows {
return Err(WorkdirError::Denied(format!(
"Workdir path `{}` exceeds the provider attachment scope",
request.path
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::AttachmentScopeExceeded,
format!(
"Workdir path `{}` exceeds the provider attachment scope",
request.path
),
));
}
let allowed = request.rules.iter().any(|rule| {
if request.permission == WorkdirToolScopePermission::Write
@@ -1383,10 +1435,13 @@ impl WorkdirSession for LocalWorkdirSession {
if allowed {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
)))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::DelegatedScopeExceeded,
format!(
"Workdir path `{}` is outside the provider-resolved delegated scope",
request.path
),
))
}
}
@@ -1432,7 +1487,12 @@ impl WorkdirSession for LocalWorkdirSession {
let logical = request.operation.path().clone();
let relative = Path::new(logical.as_str())
.strip_prefix(request.output_root.as_str())
.map_err(|_| WorkdirError::Denied("checkout search escaped output root".into()))?;
.map_err(|_| {
WorkdirError::denied(
crate::WorkdirDenialReason::CheckoutOutputRootExceeded,
"checkout search escaped output root",
)
})?;
*request.operation.path_mut() = WorkdirPath::new(relative.to_str().ok_or_else(|| {
WorkdirError::InvalidPath("checkout search path is not UTF8".into())
})?)?;
@@ -2075,7 +2135,10 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError {
WorkdirError::Io { source, .. }
if source.kind() == std::io::ErrorKind::PermissionDenied =>
{
WorkdirError::OutOfScope(path)
WorkdirError::DenialContext {
reason: io_denial_reason(&source),
source: Box::new(WorkdirError::OutOfScope(path)),
}
}
WorkdirError::Io { source, .. } => WorkdirError::Unavailable(format!(
"I/O operation failed for {logical}: {}",
@@ -2561,6 +2624,58 @@ mod tests {
use std::fs;
use tempfile::TempDir;
#[test]
fn checkout_permission_mapping_distinguishes_provider_policy_from_os_without_reclassifying() {
use crate::WorkdirDenialReason as Reason;
use crate::http::{WorkdirTransportError, WorkdirTransportErrorCode as Code};
use fs_operation::FsAccessPolicy;
let root = tempfile::tempdir().unwrap();
let access = ScopeAccess::new(
Arc::new(Scope::writable(root.path()).unwrap()),
root.path(),
None,
true,
None,
);
// Exercise the actual policy boundary, not a string-based stand-in.
let policy = access
.resolve_access_path(Path::new("/outside/private-secret"))
.unwrap_err();
let os = std::io::Error::from_raw_os_error(libc::EACCES);
for (source, reason) in [
(policy, Reason::ProviderRootExceeded),
(os, Reason::OsPermissionDenied),
] {
let error = sanitize_error(
WorkdirError::io(Path::new("/host/private-secret"), source),
&WorkdirPath::new("file").unwrap(),
);
assert_eq!(error.denial_reason(), Some(reason));
assert_eq!(error.to_string(), "path is outside allowed scope: file");
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::OutOfScope);
assert_eq!(transport.message, "Workdir path is out of scope");
assert_eq!(transport.denial_reason, Some(reason));
assert_eq!(
WorkdirTransportError::from_workdir_error(&transport.clone().into_workdir_error()),
transport
);
assert!(
!serde_json::to_string(&transport)
.unwrap()
.contains("private-secret")
);
}
// Ordinary OS I/O remains the existing generic denied/403 classification.
let error = WorkdirError::io(
Path::new("/host/private-secret"),
std::io::Error::from_raw_os_error(libc::EACCES),
);
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::Denied);
assert_eq!(transport.denial_reason, Some(Reason::OsPermissionDenied));
}
#[test]
fn checkout_error_mapping_distinguishes_absence_from_stale_execution_at_all_boundaries() {
let path = WorkdirPath::new("missing").unwrap();
+91 -44
View File
@@ -595,9 +595,10 @@ impl ScopedWorkdirSession {
if self.capabilities.supports(required) {
Ok(())
} else {
Err(WorkdirError::Denied(format!(
"scoped Workdir tools do not permit {operation}"
)))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ScopedCapabilityDenied,
format!("scoped Workdir tools do not permit {operation}"),
))
}
}
@@ -612,9 +613,12 @@ impl ScopedWorkdirSession {
.iter()
.any(|rule| rule_allows_path(rule, path, permission))
{
return Err(WorkdirError::Denied(format!(
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::LogicalScopeExceeded,
format!(
"logical workdir path `{path}` is outside the scoped {permission:?} scope"
),
));
}
}
Ok(())
@@ -626,9 +630,17 @@ impl ScopedWorkdirSession {
}
let joined = Path::new(self.cwd.as_str()).join(path.as_str());
let joined = joined.to_str().ok_or_else(|| {
WorkdirError::Denied("logical Workdir path is not valid UTF-8".into())
WorkdirError::denied(
crate::WorkdirDenialReason::InvalidScopedPath,
"logical Workdir path is not valid UTF-8",
)
})?;
FsPath::new(joined).map_err(|error| WorkdirError::Denied(error.to_string()))
FsPath::new(joined).map_err(|error| {
WorkdirError::denied(
crate::WorkdirDenialReason::InvalidScopedPath,
error.to_string(),
)
})
}
fn ensure_read(
@@ -739,9 +751,10 @@ impl ScopedWorkdirSession {
})
.await?
{
return Err(WorkdirError::Denied(format!(
"path `{path}` is leased to child Workdir tools"
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
format!("path `{path}` is leased to child Workdir tools"),
));
}
}
Ok(())
@@ -792,8 +805,9 @@ impl ScopedWorkdirSession {
) -> Result<WorkdirSessionCapabilities, WorkdirError> {
self.ensure_active()?;
if rules.is_empty() {
return Err(WorkdirError::Denied(
"workdir tool scope requires at least one logical scope rule".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::EmptyScope,
"workdir tool scope requires at least one logical scope rule",
));
}
let writable = rules
@@ -804,22 +818,25 @@ impl ScopedWorkdirSession {
&& (!self.capabilities.supports(WorkdirSessionCapability::Write)
|| !self.capabilities.supports(WorkdirSessionCapability::Edit)))
{
return Err(WorkdirError::Denied(
"parent Workdir session cannot scope the requested capabilities".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session cannot scope the requested capabilities",
));
}
if command {
if !writable {
return Err(WorkdirError::Denied(
"command execution requires a writable scoped path".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CommandRequiresWritableScope,
"command execution requires a writable scoped path",
));
}
if !self
.capabilities
.supports(WorkdirSessionCapability::Command)
{
return Err(WorkdirError::Denied(
"parent Workdir session does not support Command".into(),
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentCapabilityDenied,
"parent Workdir session does not support Command",
));
}
}
@@ -829,10 +846,13 @@ impl ScopedWorkdirSession {
.iter()
.any(|parent| rule_contains_rule(parent, requested))
{
return Err(WorkdirError::Denied(format!(
"logical workdir scope `{}` exceeds the parent tool scope",
requested.target
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ParentScopeExceeded,
format!(
"logical workdir scope `{}` exceeds the parent tool scope",
requested.target
),
));
}
}
}
@@ -866,10 +886,13 @@ impl ScopedWorkdirSession {
.iter()
.any(|rule| rule_allows_path(rule, &request.cwd, WorkdirToolScopePermission::Read))
{
return Err(WorkdirError::Denied(format!(
"scoped tool cwd `{}` is outside the readable scope",
request.cwd
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::CwdOutsideReadableScope,
format!(
"scoped tool cwd `{}` is outside the readable scope",
request.cwd
),
));
}
self.ensure_scope_targets_are_authorized(&request.rules)
.await?;
@@ -913,10 +936,13 @@ impl ScopedWorkdirSession {
})
.await?
{
return Err(WorkdirError::Denied(format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
)));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ChildWriteLeaseConflict,
format!(
"scoped write path `{}` overlaps an active child scope after provider resolution",
requested.target
),
));
}
}
}
@@ -1446,7 +1472,10 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
request: WorkdirScopeAuthorizationRequest,
) -> Result<(), WorkdirError> {
if request.permission == WorkdirToolScopePermission::Write {
return Err(WorkdirError::Denied("read-only workdir session".into()));
return Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
));
}
self.inner.authorize_scope_path(request).await
}
@@ -1509,11 +1538,17 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
}
async fn write(&self, _request: WriteRequest) -> Result<WriteResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn edit(&self, _request: EditRequest) -> Result<EditResult, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn list(&self, request: ListRequest) -> Result<ListResult, WorkdirError> {
@@ -1529,22 +1564,34 @@ impl WorkdirSession for ReadOnlyWorkdirSession {
}
async fn start_command(&self, _request: CommandRequest) -> Result<CommandHandle, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn command_status(&self, _handle: CommandHandle) -> Result<CommandStatus, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn command_output(
&self,
_request: CommandOutputRequest,
) -> Result<CommandOutput, WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn cancel_command(&self, _handle: CommandHandle) -> Result<(), WorkdirError> {
Err(WorkdirError::Denied("read-only workdir session".into()))
Err(WorkdirError::denied(
crate::WorkdirDenialReason::ReadOnlySession,
"read-only workdir session",
))
}
async fn close(&self) -> Result<(), WorkdirError> {
@@ -2226,7 +2273,7 @@ mod tests {
assert!(matches!(
child.read(read("link")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
if message.message.contains("provider-resolved delegated scope")
));
}
@@ -2252,7 +2299,7 @@ mod tests {
assert!(matches!(
child.read(read("escape")).await,
Err(WorkdirError::Denied(message))
if message.contains("provider-resolved delegated scope")
if message.message.contains("provider-resolved delegated scope")
));
}
@@ -2353,7 +2400,7 @@ mod tests {
.scope(request("alias-b", WorkdirToolScopePermission::Write))
.await,
Err(WorkdirError::Denied(message))
if message.contains("overlaps an active child scope after provider resolution")
if message.message.contains("overlaps an active child scope after provider resolution")
));
assert!(matches!(
parent
@@ -2364,7 +2411,7 @@ mod tests {
})
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
if message.message.contains("leased to child Workdir tools")
));
}
@@ -2383,7 +2430,7 @@ mod tests {
assert!(matches!(
child.scope(expanded).await,
Err(WorkdirError::Denied(message))
if message.contains("exceeds the parent tool scope")
if message.message.contains("exceeds the parent tool scope")
));
}
@@ -2452,7 +2499,7 @@ mod tests {
.write(write("secret/parent", "must-be-blocked"))
.await,
Err(WorkdirError::Denied(message))
if message.contains("leased to child Workdir tools")
if message.message.contains("leased to child Workdir tools")
));
assert_eq!(
fs::read_to_string(root.path().join("secret/from-child")).unwrap(),
+333
View File
@@ -0,0 +1,333 @@
//! Refusal reasons are selected by providers, not parsed from their messages.
use std::sync::Arc;
use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope, SymlinkPolicy};
use workdir::{
LocalWorkdirSession, ReadOnlyWorkdirSession, StatRequest, Workdir,
WorkdirDenialReason as Reason, WorkdirPath, WorkdirScopeAuthorizationRequest, WorkdirSession,
WorkdirSessionCapabilities, WorkdirToolBroker, WorkdirToolScope,
WorkdirToolScopePermission as ToolPermission, WorkdirToolScopeRule, WriteRequest,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn path(value: &str) -> WorkdirPath {
WorkdirPath::new(value).unwrap()
}
fn rule(target: &str, permission: ToolPermission) -> WorkdirToolScopeRule {
WorkdirToolScopeRule {
target: path(target),
permission,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}
}
fn scoped(target: &str, permission: ToolPermission) -> WorkdirToolScope {
WorkdirToolScope {
rules: vec![rule(target, permission)],
cwd: path(target),
command: false,
}
}
fn assert_reason(error: workdir::WorkdirError, reason: Reason) {
assert_eq!(error.denial_reason(), Some(reason), "{error}");
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.denial_reason, Some(reason));
let wire = serde_json::to_string(&transport).unwrap();
assert!(!wire.contains("private-input"));
let decoded: WorkdirTransportError = serde_json::from_str(&wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[tokio::test]
async fn local_authorize_scope_refusals_distinguish_attachment_and_delegation() {
let root = tempfile::tempdir().unwrap();
let session = LocalWorkdirSession::materialized_bound(
Workdir::new("denials"),
root.path().to_owned(),
root.path().to_owned(),
SharedScope::new(
Scope::from_config(&ScopeConfig {
allow: vec![ScopeRule {
target: root.path().join("allowed"),
permission: Permission::Write,
recursive: true,
symlink_policy: SymlinkPolicy::Resolved,
}],
deny: vec![],
})
.unwrap(),
),
WorkdirSessionCapabilities::ALL,
);
for (rules, requested, reason) in [
(
vec![rule("", ToolPermission::Read)],
"private-input",
Reason::AttachmentScopeExceeded,
),
(
vec![rule("allowed/child", ToolPermission::Read)],
"allowed/private-input",
Reason::DelegatedScopeExceeded,
),
] {
let error = session
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules,
path: path(requested),
permission: ToolPermission::Read,
})
.await
.unwrap_err();
assert_reason(error, reason);
}
}
#[tokio::test]
async fn broker_scope_refusal_matrix_preserves_distinct_reasons() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local);
let mut empty = scoped("", ToolPermission::Read);
empty.rules.clear();
let mut command = scoped("", ToolPermission::Read);
command.command = true;
let mut cwd = scoped("allowed", ToolPermission::Read);
cwd.cwd = path("private-input");
for (request, reason) in [
(empty, Reason::EmptyScope),
(command, Reason::CommandRequiresWritableScope),
(cwd, Reason::CwdOutsideReadableScope),
] {
assert_reason(parent.scope(request).await.unwrap_err(), reason);
}
let mut child_request = scoped("allowed", ToolPermission::Write);
child_request.rules[0].recursive = false;
let child = parent.scope(child_request).await.unwrap();
assert_reason(
child
.scope(scoped("private-input", ToolPermission::Read))
.await
.unwrap_err(),
Reason::ParentScopeExceeded,
);
assert_reason(
parent
.scope(scoped("allowed", ToolPermission::Write))
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
parent
.write(WriteRequest {
path: path("allowed/private-input"),
content: "unused".into(),
expected_hash: None,
})
.await
.unwrap_err(),
Reason::ChildWriteLeaseConflict,
);
assert_reason(
child
.stat(StatRequest {
path: path("private-input/nested"),
})
.await
.unwrap_err(),
Reason::DelegatedScopeExceeded,
);
}
#[tokio::test]
async fn read_only_and_scoped_capability_refusals_do_not_collapse() {
let root = tempfile::tempdir().unwrap();
let local = Arc::new(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let parent = WorkdirToolBroker::new(local.clone());
let child = parent
.scope(scoped("", ToolPermission::Read))
.await
.unwrap();
let request = WriteRequest {
path: path("private-input"),
content: "unused".into(),
expected_hash: None,
};
assert_reason(
child
.start_command(workdir::CommandRequest {
command: "must-not-run".into(),
timeout_secs: 1,
output_limit: 1,
cwd: WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await
.unwrap_err(),
Reason::ScopedCapabilityDenied,
);
let readonly = ReadOnlyWorkdirSession::new(local);
assert_reason(
readonly.write(request).await.unwrap_err(),
Reason::ReadOnlySession,
);
let error = parent
.checkout_search(workdir::CheckoutSearchRequest {
operation: workdir::CheckoutSearchOperation::List(workdir::ListRequest {
path: path("private-input"),
limit: 10,
after: None,
}),
output_root: path("allowed"),
scope_layers: vec![],
})
.await
.unwrap_err();
assert_reason(error, Reason::CheckoutOutputRootExceeded);
}
#[test]
fn denial_wire_is_allowlisted_and_old_missing_reason_remains_unknown() {
let old: WorkdirTransportError =
serde_json::from_str(r#"{"code":"denied","message":"private-input secret"}"#).unwrap();
let error = old.into_workdir_error();
assert_eq!(error.denial_reason(), None);
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(forwarded.code, Code::Denied);
assert_eq!(forwarded.message, "Workdir operation was denied");
assert_eq!(
serde_json::to_value(forwarded).unwrap(),
serde_json::json!({"code":"denied", "message":"Workdir operation was denied"})
);
for reason in ["/host/private-input", "secret", &"x".repeat(8192)] {
let input =
serde_json::json!({"code":"denied", "message":"ignored", "denial_reason":reason});
assert!(serde_json::from_value::<WorkdirTransportError>(input).is_err());
}
}
// A provider with filesystem methods, but no provider-side scope resolver.
#[derive(Debug)]
struct UnresolvedProvider(LocalWorkdirSession);
#[async_trait::async_trait]
impl WorkdirSession for UnresolvedProvider {
fn workdir(&self) -> &Workdir {
self.0.workdir()
}
fn capabilities(&self) -> WorkdirSessionCapabilities {
self.0.capabilities()
}
async fn stat(&self, r: StatRequest) -> Result<workdir::StatResult, workdir::WorkdirError> {
self.0.stat(r).await
}
async fn read(
&self,
r: workdir::ReadRequest,
) -> Result<workdir::ReadResult, workdir::WorkdirError> {
self.0.read(r).await
}
async fn write(&self, r: WriteRequest) -> Result<workdir::WriteResult, workdir::WorkdirError> {
self.0.write(r).await
}
async fn edit(
&self,
r: workdir::EditRequest,
) -> Result<workdir::EditResult, workdir::WorkdirError> {
self.0.edit(r).await
}
async fn list(
&self,
r: workdir::ListRequest,
) -> Result<workdir::ListResult, workdir::WorkdirError> {
self.0.list(r).await
}
async fn glob(
&self,
r: workdir::GlobRequest,
) -> Result<workdir::GlobResult, workdir::WorkdirError> {
self.0.glob(r).await
}
async fn grep(
&self,
r: workdir::GrepRequest,
) -> Result<workdir::GrepResult, workdir::WorkdirError> {
self.0.grep(r).await
}
async fn start_command(
&self,
r: workdir::CommandRequest,
) -> Result<workdir::CommandHandle, workdir::WorkdirError> {
self.0.start_command(r).await
}
async fn command_status(
&self,
h: workdir::CommandHandle,
) -> Result<workdir::CommandStatus, workdir::WorkdirError> {
self.0.command_status(h).await
}
async fn command_output(
&self,
r: workdir::CommandOutputRequest,
) -> Result<workdir::CommandOutput, workdir::WorkdirError> {
self.0.command_output(r).await
}
async fn cancel_command(&self, h: workdir::CommandHandle) -> Result<(), workdir::WorkdirError> {
self.0.cancel_command(h).await
}
async fn close(&self) -> Result<(), workdir::WorkdirError> {
self.0.close().await
}
}
#[tokio::test]
async fn providers_without_scope_resolution_fail_closed_with_specific_reasons() {
let root = tempfile::tempdir().unwrap();
let provider = UnresolvedProvider(LocalWorkdirSession::new(
Scope::writable(root.path()).unwrap(),
root.path().to_owned(),
));
let rules = vec![rule("", ToolPermission::Read)];
assert_reason(
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: rules.clone(),
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap_err(),
Reason::ScopeResolutionUnavailable,
);
assert_reason(
provider
.scope_rules_overlap(workdir::WorkdirScopeOverlapRequest {
left: rules[0].clone(),
right: rules[0].clone(),
})
.await
.unwrap_err(),
Reason::ScopeComparisonUnavailable,
);
let mut logical = rules;
logical[0].symlink_policy = SymlinkPolicy::Logical;
provider
.authorize_scope_path(WorkdirScopeAuthorizationRequest {
rules: logical,
path: path("private-input"),
permission: ToolPermission::Read,
})
.await
.unwrap();
}
+288
View File
@@ -0,0 +1,288 @@
use std::path::Path;
use fs_operation::{AtomicWriteMode, CheckedTarget, FsAccessPolicy, FsDenialReason, FsError};
use workdir::{
WorkdirDenialReason as Reason, WorkdirError,
http::{WorkdirTransportError, WorkdirTransportErrorCode as Code},
};
fn assert_origin(source: std::io::Error, reason: Reason, message: &str) {
assert_eq!(source.kind(), std::io::ErrorKind::PermissionDenied);
assert_eq!(source.raw_os_error(), None);
assert_eq!(source.to_string(), message);
assert_transport_reason(source, reason);
}
fn assert_transport_reason(source: std::io::Error, reason: Reason) {
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
assert!(matches!(&error, WorkdirError::Io { .. }));
assert_eq!(error.denial_reason(), Some(reason));
let transport = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(transport.code, Code::Denied);
assert_eq!(transport.code.http_status(), 403);
assert_eq!(transport.message, "Workdir operation was denied");
let wire = serde_json::to_value(&transport).unwrap();
assert_eq!(wire["denial_reason"], reason.as_str());
assert!(!wire.to_string().contains("private-input"));
assert!(!wire.to_string().contains("secret"));
let decoded: WorkdirTransportError = serde_json::from_value(wire).unwrap();
assert_eq!(
WorkdirTransportError::from_workdir_error(&decoded.into_workdir_error()),
transport
);
}
#[test]
fn synthetic_fs_origins_survive_fs_workdir_and_transport_conversion() {
for (origin, reason, message) in [
(
FsDenialReason::ProviderRootExceeded,
Reason::ProviderRootExceeded,
"path is outside provider root",
),
(
FsDenialReason::ProviderSymlinkDenied,
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
),
(
FsDenialReason::CheckoutTargetDenied,
Reason::CheckoutTargetDenied,
"checkout target denied",
),
(
FsDenialReason::CheckoutCreateParentDenied,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
),
] {
assert_origin(origin.into_io_error(), reason, message);
}
}
#[test]
fn custom_permission_denied_is_not_guessed_from_kind_or_message() {
for message in [
"Permission denied (os error 13)",
"checkout target denied",
"/private-input/secret",
] {
assert_transport_reason(
std::io::Error::new(std::io::ErrorKind::PermissionDenied, message),
Reason::UnclassifiedPermissionDenied,
);
}
}
#[cfg(unix)]
#[test]
fn raw_os_permission_denied_retains_errno_and_os_origin() {
for errno in [libc::EACCES, libc::EPERM] {
let source = std::io::Error::from_raw_os_error(errno);
let error = WorkdirError::from(FsError::io("/private-input/secret", source));
let WorkdirError::Io { source, .. } = &error else {
panic!("classification changed")
};
assert_eq!(source.raw_os_error(), Some(errno));
assert_transport_reason(
std::io::Error::from_raw_os_error(errno),
Reason::OsPermissionDenied,
);
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_confined_open_and_write_refusals_keep_distinct_origins() {
use fs_operation::{
atomic_write_beneath_no_symlinks_at, open_beneath_no_symlinks, open_beneath_no_symlinks_at,
open_root_no_symlinks,
};
let root = tempfile::tempdir().unwrap();
let directory = open_root_no_symlinks(root.path()).unwrap();
let outside = root.path().parent().unwrap().join("private-input");
for source in [
open_beneath_no_symlinks_at(&directory, &outside).unwrap_err(),
open_beneath_no_symlinks(root.path(), &outside).unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
&outside,
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
atomic_write_beneath_no_symlinks_at(
&directory,
Path::new("../private-input"),
b"unused",
AtomicWriteMode::CreateNew,
|| Ok(()),
)
.unwrap_err(),
] {
assert_origin(
source,
Reason::ProviderRootExceeded,
"path is outside provider root",
);
}
std::fs::write(root.path().join("file"), "secret").unwrap();
std::os::unix::fs::symlink("file", root.path().join("link")).unwrap();
assert_origin(
open_beneath_no_symlinks_at(&directory, Path::new("link")).unwrap_err(),
Reason::ProviderSymlinkDenied,
"symbolic links are not permitted by this provider",
);
}
struct ExactWrite<'a>(&'a Path);
impl FsAccessPolicy for ExactWrite<'_> {
fn is_readable(&self, _: &Path) -> bool {
true
}
fn is_writable(&self, path: &Path) -> bool {
path == self.0
}
}
#[cfg(target_os = "linux")]
#[test]
fn actual_checked_target_and_create_parent_refusals_keep_distinct_origins() {
let root = tempfile::tempdir().unwrap();
let directory = fs_operation::open_root_no_symlinks(root.path()).unwrap();
let destination = root.path().join("parent/file");
let policy = ExactWrite(&destination);
let source = CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, true)
.err()
.expect("target policy must reject");
assert_origin(
source,
Reason::CheckoutTargetDenied,
"checkout target denied",
);
let target =
CheckedTarget::pin(&directory, Path::new(""), root.path(), &policy, false).unwrap();
let create = target.create_access("parent/file".into(), destination.clone());
let error = fs_operation::run_write(
root.path(),
fs_operation::WriteRequest {
path: fs_operation::FsPath::new("parent/file").unwrap(),
content: "unused".into(),
expected_hash: None,
},
&create,
)
.unwrap_err();
let FsError::Io { source, .. } = error else {
panic!("expected parent policy denial")
};
assert_origin(
source,
Reason::CheckoutCreateParentDenied,
"Create parent denied",
);
assert!(!root.path().join("parent").exists());
}
#[test]
fn every_transport_code_forwards_supplied_reason_without_reclassification() {
// JSON is the provider boundary. An optional reason is independent of code/status.
for code in [
"not_found",
"conflict",
"outcome_unknown",
"unsupported",
"invalid_request",
"denied",
"out_of_scope",
"symlink_out_of_scope",
"broken_symlink",
"symlink_target_is_directory",
"read_only",
"is_directory",
"symlink_directory_not_traversed",
"unknown_command",
"unavailable",
"io",
"transport",
"internal",
] {
let decode = |reason| {
serde_json::from_value::<WorkdirTransportError>(serde_json::json!({
"code": code, "message": "private-input secret", "denial_reason": reason,
}))
.unwrap()
.into_workdir_error()
};
let baseline_error = decode(None::<Reason>);
let baseline = WorkdirTransportError::from_workdir_error(&baseline_error);
let error = decode(Some(Reason::CheckoutTargetDenied));
let forwarded = WorkdirTransportError::from_workdir_error(&error);
assert_eq!(
error.denial_reason(),
Some(Reason::CheckoutTargetDenied),
"{code}"
);
assert_eq!(forwarded.code, baseline.code, "{code}");
assert_eq!(
forwarded.code.http_status(),
baseline.code.http_status(),
"{code}"
);
assert_eq!(forwarded.message, baseline.message, "{code}");
assert_eq!(error.to_string(), baseline_error.to_string(), "{code}");
// Missing provider reasons retain the deliberate existing fallback: canonical
// path classifications supply their known reason; all other codes stay unknown.
assert_eq!(
baseline_error.denial_reason(),
match code {
"out_of_scope" => Some(Reason::PathOutOfScope),
"symlink_out_of_scope" => Some(Reason::SymlinkTargetOutOfScope),
"read_only" => Some(Reason::PathReadOnly),
_ => None,
},
"{code}"
);
}
}
#[test]
fn diagnostic_labels_match_allowlisted_wire_values() {
for label in [
"scope_resolution_unavailable",
"scope_comparison_unavailable",
"scoped_capability_denied",
"logical_scope_exceeded",
"invalid_scoped_path",
"child_write_lease_conflict",
"empty_scope",
"parent_capability_denied",
"command_requires_writable_scope",
"parent_scope_exceeded",
"cwd_outside_readable_scope",
"read_only_session",
"external_root_symlink",
"external_root_changed",
"external_root_moved",
"external_scope_symlink",
"attachment_scope_exceeded",
"delegated_scope_exceeded",
"checkout_output_root_exceeded",
"provider_root_exceeded",
"provider_symlink_denied",
"checkout_target_denied",
"checkout_create_parent_denied",
"checkout_search_path_denied",
"checkout_enumeration_denied",
"path_out_of_scope",
"symlink_target_out_of_scope",
"path_read_only",
"os_permission_denied",
"unclassified_permission_denied",
] {
let reason: Reason = serde_json::from_value(serde_json::json!(label)).unwrap();
assert_eq!(reason.as_str(), label);
assert_eq!(serde_json::to_value(reason).unwrap(), label);
}
}
@@ -0,0 +1,110 @@
#![cfg(feature = "http-client")]
//! Loopback fake at the HTTP provider boundary; no product process or live service.
use std::{
io::{Read, Write},
net::{TcpListener, TcpStream},
thread,
time::Duration,
};
use workdir::{
StatRequest, Workdir, WorkdirDenialReason as Reason, WorkdirPath, WorkdirSession,
http::{
OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirTransportError,
WorkdirTransportErrorCode as Code,
},
};
fn reply(listener: &TcpListener, status: &str, body: &str) {
let (mut stream, _) = listener.accept().unwrap();
stream
.set_read_timeout(Some(Duration::from_secs(5)))
.unwrap();
stream
.set_write_timeout(Some(Duration::from_secs(5)))
.unwrap();
read_request(&mut stream);
write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", body.len()).unwrap();
}
fn read_request(stream: &mut TcpStream) {
let mut bytes = Vec::new();
let mut byte = [0];
while !bytes.ends_with(b"\r\n\r\n") {
stream.read_exact(&mut byte).unwrap();
bytes.push(byte[0]);
assert!(bytes.len() < 16384);
}
let headers = String::from_utf8(bytes).unwrap();
let length: usize = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse().unwrap())
})
.unwrap_or(0);
assert!(length < 16384);
stream.read_exact(&mut vec![0; length]).unwrap();
}
#[tokio::test]
async fn remote_http_denials_forward_only_typed_reasons_and_old_missing_reason() {
for (code, reason) in [
(Code::Denied, Some(Reason::ReadOnlySession)),
(Code::Denied, Some(Reason::OsPermissionDenied)),
(Code::OutOfScope, Some(Reason::OsPermissionDenied)),
(Code::Denied, None),
] {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
let address = listener.local_addr().unwrap();
// Host paths and secrets in provider-authored text must not become diagnostics.
let mut error = serde_json::json!({"code":code, "message":"/secret/host/root bearer-secret command-secret"});
if let Some(reason) = reason {
error["denial_reason"] = serde_json::to_value(reason).unwrap();
}
let server = thread::spawn(move || {
reply(
&listener,
"200 OK",
r#"{"session_id":"s","workdir_id":"w","capabilities":{"bits":63}}"#,
);
reply(&listener, "403 Forbidden", &error.to_string());
});
let remote = RemoteWorkdirSession::open(
reqwest::Client::builder()
.no_proxy()
.timeout(Duration::from_secs(5))
.build()
.unwrap(),
format!("http://{address}").parse().unwrap(),
"request-secret",
Workdir::new("w").id().clone(),
OpenWorkdirSessionRequest::default(),
)
.await
.unwrap();
let denied = remote
.stat(StatRequest {
path: WorkdirPath::new("file").unwrap(),
})
.await
.unwrap_err();
assert_eq!(denied.denial_reason(), reason);
assert!(!denied.to_string().contains("secret"));
let forwarded = WorkdirTransportError::from_workdir_error(&denied);
assert_eq!(forwarded.denial_reason, reason);
assert_eq!(forwarded.code, code);
assert_eq!(forwarded.code.http_status(), 403);
assert_eq!(
forwarded.message,
if code == Code::Denied {
"Workdir operation was denied"
} else {
"Workdir path is out of scope"
}
);
let wire = serde_json::to_string(&forwarded).unwrap();
assert!(!wire.contains("secret"));
assert!(wire.len() < 256);
server.join().unwrap();
}
}
+1
View File
@@ -2350,6 +2350,7 @@ impl RuntimeHttpWorkdirError {
Self {
status,
payload: WorkdirTransportError {
denial_reason: None,
code,
message: message.into(),
},
+18 -9
View File
@@ -191,15 +191,24 @@ impl Provider {
};
let observation = match selected.session.checkout_observe(target.clone()).await {
Ok(o) => o,
Err(
workdir::WorkdirError::Denied(_)
| workdir::WorkdirError::OutOfScope(_)
| workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::SessionClosed
| workdir::WorkdirError::UnsupportedOperation(_)
| workdir::WorkdirError::Unsupported(_),
) => return Ok(None),
Err(_) => {
Err(failure) => {
// Internal diagnostic enrichment is transparent to WIP's existing
// unavailable/refusal classification, including remote providers.
let mut classification = &failure;
while let workdir::WorkdirError::DenialContext { source, .. } = classification {
classification = source.as_ref();
}
if matches!(
classification,
workdir::WorkdirError::Denied(_)
| workdir::WorkdirError::OutOfScope(_)
| workdir::WorkdirError::NotFound(_)
| workdir::WorkdirError::SessionClosed
| workdir::WorkdirError::UnsupportedOperation(_)
| workdir::WorkdirError::Unsupported(_)
) {
return Ok(None);
}
return Err(error(
ProtocolErrorCode::Internal,
"checkout observation unavailable",
+40
View File
@@ -36,6 +36,7 @@ struct ProviderState {
requests: Mutex<Vec<(String, String)>>,
operations: Mutex<Vec<WorkdirSessionOperation>>,
write_fault: Mutex<Option<WriteResponseFault>>,
observe_refusal: Mutex<bool>,
}
impl ProviderState {
@@ -49,6 +50,7 @@ impl ProviderState {
return Err((
StatusCode::UNAUTHORIZED,
AxumJson(WorkdirTransportError {
denial_reason: None,
code: WorkdirTransportErrorCode::Denied,
message: "provider authorization denied".into(),
}),
@@ -140,6 +142,16 @@ async fn operate(
.lock()
.unwrap()
.push(request.operation.clone());
if matches!(
&request.operation,
WorkdirSessionOperation::CheckoutObserve(_)
) && *state.observe_refusal.lock().unwrap()
{
return provider_error(WorkdirError::DenialContext {
reason: workdir::WorkdirDenialReason::OsPermissionDenied,
source: Box::new(WorkdirError::OutOfScope("/private/provider/path".into())),
});
}
let result =
dispatch_workdir_session_operation(state.session.as_ref(), request.operation).await;
match result {
@@ -225,6 +237,7 @@ impl HttpFixture {
requests: Mutex::new(Vec::new()),
operations: Mutex::new(Vec::new()),
write_fault: Mutex::new(None),
observe_refusal: Mutex::new(false),
});
let app = Router::new()
.route(
@@ -755,3 +768,30 @@ async fn remote_checkout_list_pages_and_direct_inspect_read_use_provider_without
.any(|op| matches!(op, WorkdirSessionOperation::List(_)))
);
}
#[tokio::test]
async fn remote_checkout_diagnostic_context_keeps_wip_refusal_classification_without_retry() {
let fixture = HttpFixture::new(WorkdirSessionCapabilities::READ_ONLY).await;
*fixture.state.observe_refusal.lock().unwrap() = true;
let remote = fixture.open(TOKEN).await.unwrap();
let (runtime, _) = native_runtime(Arc::new(remote));
let failure = runtime
.inspect("/checkouts/main/src/deep/a.txt".into(), true)
.await
.unwrap_err();
let text = failure.to_string();
assert!(
!text.contains("checkout observation unavailable"),
"refusal became an internal error: {text}"
);
assert!(!text.contains("/private/provider/path"));
assert!(
text.contains("protocol failure (NotFound)"),
"expected unavailable Object: {text}"
);
assert_eq!(
fixture.state.operations.lock().unwrap().len(),
1,
"refusal was retried"
);
}
@@ -1236,6 +1236,7 @@ mod tests {
WorkspaceResponse {
status,
body: serde_json::to_string(&workdir::http::WorkdirTransportError {
denial_reason: None,
code,
message: message.to_string(),
})
+14 -3
View File
@@ -794,6 +794,10 @@ pub struct RuntimeSessionAttachment {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RuntimeRegistryError {
WorkdirSessionOpenFailed {
runtime_id: String,
transport: workdir::http::WorkdirTransportError,
},
InvalidIdentifier {
kind: &'static str,
value: String,
@@ -823,11 +827,19 @@ impl RuntimeRegistryError {
worker.worker_id, worker.runtime_id
),
Self::RuntimeOperationFailed { message, .. } => message.clone(),
Self::WorkdirSessionOpenFailed { transport, .. } => transport.message.clone(),
}
}
pub fn into_error(self) -> Error {
match self {
Self::WorkdirSessionOpenFailed {
runtime_id,
transport,
} => Error::WorkdirSessionOpenFailed {
runtime_id,
transport,
},
Self::InvalidIdentifier { kind, value } => Error::InvalidRuntimeIdentifier {
kind: kind.to_string(),
value,
@@ -1885,10 +1897,9 @@ impl RuntimeRegistry {
runtime
.open_workdir_session(working_directory_id, owner_worker_id)
.await
.map_err(|error| RuntimeRegistryError::RuntimeOperationFailed {
.map_err(|error| RuntimeRegistryError::WorkdirSessionOpenFailed {
runtime_id: runtime_id.to_string(),
code: "workdir_session_open_failed".to_string(),
message: error.to_string(),
transport: workdir::http::WorkdirTransportError::from_workdir_error(&error),
})
}
+5
View File
@@ -106,6 +106,11 @@ pub enum Error {
code: String,
message: String,
},
#[error("runtime `{runtime_id}` operation failed (workdir_session_open_failed): {}", transport.message)]
WorkdirSessionOpenFailed {
runtime_id: String,
transport: workdir::http::WorkdirTransportError,
},
#[error("runtime `{runtime_id}` does not support `{capability}`")]
RuntimeCapabilityUnsupported {
runtime_id: String,
+212 -63
View File
@@ -11458,6 +11458,15 @@ impl server_api::ServerApi for ServerApiContractService {
server_api::CurrentWorkerWorkdirOperationResponse,
server_api::WorkdirOperationApiError,
> {
let fallback_log = server_api::WorkdirOperationLogContext {
operation: workdir_operation_label(&request.operation),
stage: "worker_identity",
workspace_id_hash: crate::worker_source::diagnostic_id_hash(&workspace_id),
attachment_alias_hash: crate::worker_source::diagnostic_id_hash(
&request.target_workdir,
),
..Default::default()
};
let Json(response) = scoped_execute_current_worker_workdir_operation(
State(
self.workspace_api()
@@ -11465,22 +11474,18 @@ impl server_api::ServerApi for ServerApiContractService {
.clone(),
),
AxumPath(ScopedWorkspacePath { workspace_id }),
current_worker_contract_headers(&context)
.map_err(server_api::WorkdirOperationApiError::api)?,
current_worker_contract_headers(&context).map_err(|error| {
let mut error = server_api::WorkdirOperationApiError::api(error);
error.workdir_log = Some(fallback_log);
error
})?,
Json(WorkspaceWorkdirSessionOperationRequest {
target_workdir: request.target_workdir,
operation: request.operation,
}),
)
.await
.map_err(|error| match error {
InternalWorkdirOperationError::Api(error) => {
server_api::WorkdirOperationApiError::api(error.into_repository_api_error())
}
InternalWorkdirOperationError::Provider(error) => {
server_api::WorkdirOperationApiError::provider(error)
}
})?;
.map_err(InternalWorkdirOperationError::into_contract_error)?;
Ok(server_api::CurrentWorkerWorkdirOperationResponse(response))
}
@@ -11722,6 +11727,10 @@ fn build_inner_router(api: WorkspaceApi) -> Router {
async fn log_failed_api_response(request: Request, next: Next) -> Response {
let method = request.method().clone();
let uri = request.uri().clone();
let request_token_id_hash = request
.extensions()
.get::<crate::worker_source::VerifiedRuntimeRequestSource>()
.and_then(|source| source.token_id_hash.clone());
let response = next.run(request).await;
let status = response.status();
@@ -11742,11 +11751,23 @@ async fn log_failed_api_response(request: Request, next: Next) -> Response {
.get::<ApiErrorLog>()
.or(generated_error.as_ref());
let event = api_failure_log_event(&method, &uri, status, error);
let context = error.and_then(|error| error.workdir_log.as_ref());
let workdir_path = context.map(|_| bounded_auth_log_value(uri.path(), 512));
let workdir_method = context.map(|_| bounded_auth_log_value(method.as_str(), 32));
tracing::error!(
target: "yoi::api",
event = event.event,
method = event.method,
path = event.path,
method = workdir_method.as_deref().unwrap_or(event.method),
path = workdir_path.as_deref().unwrap_or(event.path),
operation = context.map(|ctx| ctx.operation),
workdir_stage = context.map(|ctx| ctx.stage),
workspace_id_hash = context.map(|ctx| ctx.workspace_id_hash.as_str()),
runtime_id_hash = context.and_then(|ctx| ctx.runtime_id_hash.as_deref()),
worker_id_hash = context.and_then(|ctx| ctx.worker_id_hash.as_deref()),
workdir_id_hash = context.and_then(|ctx| ctx.workdir_id_hash.as_deref()),
attachment_alias_hash = context.map(|ctx| ctx.attachment_alias_hash.as_str()),
request_token_id_hash = context.and(request_token_id_hash.as_deref()),
denial_reason = error.and_then(|error| error.denial_reason).map(|reason| reason.as_str()),
status = event.status,
kind = event.kind.unwrap_or("unknown"),
message = event.message.unwrap_or(""),
@@ -17224,9 +17245,10 @@ fn ticket_notification_delivery_error_category(
Err(RuntimeRegistryError::UnknownRuntime(_)) => Some("unknown_runtime"),
Err(RuntimeRegistryError::UnknownHost(_)) => Some("unknown_host"),
Err(RuntimeRegistryError::UnknownWorker { .. }) => Some("unknown_worker"),
Err(RuntimeRegistryError::RuntimeOperationFailed { .. }) => {
Some("runtime_operation_failed")
}
Err(
RuntimeRegistryError::WorkdirSessionOpenFailed { .. }
| RuntimeRegistryError::RuntimeOperationFailed { .. },
) => Some("runtime_operation_failed"),
}
}
@@ -18217,6 +18239,33 @@ fn validated_current_worker_attachment(
enum InternalWorkdirOperationError {
Api(ApiError),
Provider(WorkdirTransportError),
Contextual {
error: Box<InternalWorkdirOperationError>,
context: server_api::WorkdirOperationLogContext,
},
}
impl InternalWorkdirOperationError {
fn into_contract_error(self) -> server_api::WorkdirOperationApiError {
match self {
Self::Api(error) => {
let reason = match &error.error {
Error::WorkdirSessionOpenFailed { transport, .. } => transport.denial_reason,
_ => None,
};
let mut error =
server_api::WorkdirOperationApiError::api(error.into_repository_api_error());
error.denial_reason = reason;
error
}
Self::Provider(error) => server_api::WorkdirOperationApiError::provider(error),
Self::Contextual { error, context } => {
let mut error = error.into_contract_error();
error.workdir_log = Some(context);
error
}
}
}
}
impl From<ApiError> for InternalWorkdirOperationError {
@@ -18239,21 +18288,35 @@ impl From<WorkdirTransportError> for InternalWorkdirOperationError {
impl IntoResponse for InternalWorkdirOperationError {
fn into_response(self) -> Response {
match self {
Self::Api(error) => error.into_response(),
Self::Provider(error) => {
let status = StatusCode::from_u16(error.code.http_status())
.expect("Workdir transport error status is valid");
let log = ApiErrorLog {
kind: format!("workdir_session_operation_{}", error.code.as_str()),
message: error.message.clone(),
diagnostics: Vec::new(),
};
let mut response = (status, Json(error)).into_response();
response.extensions_mut().insert(log);
response
}
}
let error = self.into_contract_error();
let status = StatusCode::from_u16(api_macros::HttpError::status_code(&error))
.expect("Workdir error status is valid");
let log = ApiErrorLog::from_workdir_operation_error(&error, status);
let mut response = (status, Json(error)).into_response();
response.extensions_mut().insert(log);
response
}
}
fn workdir_operation_label(operation: &WorkdirSessionOperation) -> &'static str {
match operation {
WorkdirSessionOperation::AuthorizeScope(_) => "authorize_scope",
WorkdirSessionOperation::ScopeRulesOverlap(_) => "scope_rules_overlap",
WorkdirSessionOperation::CheckoutSearch(_) => "checkout_search",
WorkdirSessionOperation::CheckoutObserve(_) => "checkout_observe",
WorkdirSessionOperation::CheckoutExecute(_) => "checkout_execute",
WorkdirSessionOperation::Stat(_) => "stat",
WorkdirSessionOperation::Read(_) => "read",
WorkdirSessionOperation::ReadBytes(_) => "read_bytes",
WorkdirSessionOperation::Write(_) => "write",
WorkdirSessionOperation::Edit(_) => "edit",
WorkdirSessionOperation::List(_) => "list",
WorkdirSessionOperation::Glob(_) => "glob",
WorkdirSessionOperation::Grep(_) => "grep",
WorkdirSessionOperation::CommandStart(_) => "command_start",
WorkdirSessionOperation::CommandStatus(_) => "command_status",
WorkdirSessionOperation::CommandOutput(_) => "command_output",
WorkdirSessionOperation::CommandCancel(_) => "command_cancel",
}
}
@@ -18263,19 +18326,34 @@ async fn scoped_execute_current_worker_workdir_operation(
headers: HeaderMap,
Json(request): Json<WorkspaceWorkdirSessionOperationRequest>,
) -> std::result::Result<Json<WorkdirSessionOperationResult>, InternalWorkdirOperationError> {
let mut context = server_api::WorkdirOperationLogContext {
operation: workdir_operation_label(&request.operation),
workspace_id_hash: crate::worker_source::diagnostic_id_hash(&path.workspace_id),
attachment_alias_hash: crate::worker_source::diagnostic_id_hash(&request.target_workdir),
..Default::default()
};
let result = async {
context.stage = "workspace_scope";
validate_workspace_scope(&api, &path.workspace_id)?;
context.stage = "worker_identity";
let worker = current_worker_identity(&api, &path.workspace_id, &headers)?;
context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id));
context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id));
let target_workdir = request.target_workdir;
let result = match request.operation {
WorkdirSessionOperation::CommandStart(command) => {
let session_lock = current_worker_session_lock(&api, &worker);
let _session_guard = session_lock.lock().await;
context.stage = "attachment_validation";
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
context.stage = "session_open";
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
context.stage = "provider_dispatch";
let provider_handle = source
.start_command(command)
.await
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?;
.map_err(workdir_operation_transport_error)?;
let registered_source = api
.workdir_sessions
.lock()
@@ -18301,31 +18379,31 @@ async fn scoped_execute_current_worker_workdir_operation(
}
WorkdirSessionOperation::CommandStatus(external_handle) => {
let (session, provider_handle) =
current_worker_command_session(&api, &worker, &target_workdir, &external_handle)?;
current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?;
session
.command_status(provider_handle)
.await
.map(WorkdirSessionOperationResult::CommandStatus)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
WorkdirSessionOperation::CommandOutput(mut output) => {
let (session, provider_handle) =
current_worker_command_session(&api, &worker, &target_workdir, &output.handle)?;
current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context)?;
output.handle = provider_handle;
session
.command_output(output)
.await
.map(WorkdirSessionOperationResult::CommandOutput)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
WorkdirSessionOperation::CommandCancel(external_handle) => {
let (session, provider_handle) =
current_worker_command_session(&api, &worker, &target_workdir, &external_handle)?;
current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?;
session
.cancel_command(provider_handle)
.await
.map(|()| WorkdirSessionOperationResult::CommandCancel)
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
operation @ (WorkdirSessionOperation::AuthorizeScope(_)
| WorkdirSessionOperation::ScopeRulesOverlap(_)
@@ -18342,14 +18420,23 @@ async fn scoped_execute_current_worker_workdir_operation(
| WorkdirSessionOperation::Grep(_)) => {
let session_lock = current_worker_session_lock(&api, &worker);
let _session_guard = session_lock.lock().await;
context.stage = "attachment_validation";
let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?;
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
context.stage = "session_open";
let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?;
context.stage = "provider_dispatch";
execute_workdir_session_operation(&source, operation)
.await
.map_err(|error| current_worker_workdir_operation_error(&worker, error))?
.map_err(workdir_operation_transport_error)?
}
};
Ok(Json(result))
}.await;
result.map_err(|error| InternalWorkdirOperationError::Contextual {
error: Box::new(error),
context,
})
}
fn current_worker_command_session(
@@ -18357,26 +18444,27 @@ fn current_worker_command_session(
worker: &RuntimeWorkerRef,
target_workdir: &str,
external_handle: &CommandHandle,
context: &mut server_api::WorkdirOperationLogContext,
) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), InternalWorkdirOperationError> {
let _link = validated_current_worker_attachment(api, worker, target_workdir)?;
context.stage = "attachment_validation";
let link = validated_current_worker_attachment(api, worker, target_workdir)?;
context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id));
context.stage = "command_lookup";
let command = api
.workdir_sessions
.lock()
.expect("Workdir session registry lock poisoned")
.command(worker, target_workdir, external_handle)
.ok_or_else(|| {
InternalWorkdirOperationError::Provider(current_worker_workdir_operation_error(
worker,
InternalWorkdirOperationError::Provider(workdir_operation_transport_error(
workdir::WorkdirError::UnknownCommand(external_handle.0.clone()),
))
})?;
context.stage = "provider_dispatch";
Ok((command.source, command.provider_handle))
}
fn current_worker_workdir_operation_error(
_worker: &RuntimeWorkerRef,
error: workdir::WorkdirError,
) -> WorkdirTransportError {
fn workdir_operation_transport_error(error: workdir::WorkdirError) -> WorkdirTransportError {
WorkdirTransportError::from_workdir_error(&error)
}
@@ -23469,6 +23557,7 @@ async fn serve_external_workdir_provider(
Some(ExternalProviderCommand::Operation { operation_id, operation, response }) => {
if revoking {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir provider shutdown is in progress".to_string(),
}));
@@ -23476,6 +23565,7 @@ async fn serve_external_workdir_provider(
}
if pending.len() >= 16 {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir provider is at its in-flight operation limit".to_string(),
}));
@@ -23486,6 +23576,7 @@ async fn serve_external_workdir_provider(
Ok(operation) => operation,
Err(message) => {
let _ = response.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message,
}));
@@ -23542,6 +23633,7 @@ async fn serve_external_workdir_provider(
Err(error.into_transport_error())
}
ExternalWorkdirOperationOutcome::Cancelled => Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Unavailable,
message: "External Workdir operation was cancelled".to_string(),
}),
@@ -24023,6 +24115,9 @@ fn workdir_runtime_failure_code(error: &RuntimeRegistryError) -> String {
RuntimeRegistryError::UnknownRuntime(_) | RuntimeRegistryError::UnknownHost(_) => {
"runtime_unavailable".to_string()
}
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => {
"workdir_session_open_failed".to_string()
}
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => code.clone(),
RuntimeRegistryError::InvalidIdentifier { .. } => "invalid_runtime".to_string(),
RuntimeRegistryError::UnknownWorker { .. } => "runtime_workdir_lookup_failed".to_string(),
@@ -24534,6 +24629,7 @@ fn runtime_reports_workdir_not_found(result: &crate::hosts::RuntimeWorkingDirect
fn classify_workdir_provider_error(error: &RuntimeRegistryError) -> (&'static str, bool) {
match error {
RuntimeRegistryError::WorkdirSessionOpenFailed { .. } => ("provider_unavailable", true),
RuntimeRegistryError::UnknownRuntime(_) => ("runtime_unavailable", true),
RuntimeRegistryError::RuntimeOperationFailed { code, .. } => {
let category =
@@ -32572,6 +32668,8 @@ struct ApiErrorLog {
kind: String,
message: String,
diagnostics: Vec<RuntimeDiagnostic>,
workdir_log: Option<server_api::WorkdirOperationLogContext>,
denial_reason: Option<workdir::WorkdirDenialReason>,
}
impl ApiErrorLog {
@@ -32584,9 +32682,11 @@ impl ApiErrorLog {
kind: format!("workdir_session_operation_{}", code.as_str()),
message: sanitize_backend_error(&error.message),
diagnostics: Vec::new(),
workdir_log: error.workdir_log.clone(),
denial_reason: error.denial_reason,
};
}
Self::from_repository_error(&server_api::RepositoryApiError::new(
let mut log = Self::from_repository_error(&server_api::RepositoryApiError::new(
status.as_u16(),
error.error.as_deref().unwrap_or_else(|| {
status
@@ -32595,11 +32695,23 @@ impl ApiErrorLog {
}),
&error.message,
error.diagnostics.clone().unwrap_or_default(),
))
));
log.workdir_log = error.workdir_log.clone();
log.denial_reason = error.denial_reason;
if log.workdir_log.is_some() {
// Existing public API details are unchanged; do not copy request-derived
// alias/handle text or session-open errors into operational diagnostics.
log.kind = format!("workdir_session_operation_api_{}", status.as_u16());
log.message = "Workdir operation was rejected before provider dispatch".to_string();
log.diagnostics.clear();
}
log
}
fn from_repository_error(error: &server_api::RepositoryApiError) -> Self {
Self {
workdir_log: None,
denial_reason: None,
kind: error
.diagnostics
.first()
@@ -32632,6 +32744,11 @@ impl From<merge_request::MergeRequestError> for ApiError {
impl From<Error> for ApiError {
fn from(error: Error) -> Self {
let diagnostics = match &error {
Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic {
code: "workdir_session_open_failed".to_string(),
severity: HostDiagnosticSeverity::Error,
message: transport.message.clone(),
}],
Error::RuntimeOperationFailed { code, message, .. } => vec![RuntimeDiagnostic {
code: code.clone(),
severity: HostDiagnosticSeverity::Error,
@@ -32850,13 +32967,18 @@ fn api_error_status(error: &Error) -> StatusCode {
StatusCode::SERVICE_UNAVAILABLE
}
Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE,
Error::RuntimeOperationFailed { .. } => StatusCode::BAD_GATEWAY,
Error::WorkdirSessionOpenFailed { .. } | Error::RuntimeOperationFailed { .. } => {
StatusCode::BAD_GATEWAY
}
_ => StatusCode::INTERNAL_SERVER_ERROR,
}
}
fn api_error_response_message(error: &Error) -> String {
match error {
Error::WorkdirSessionOpenFailed { transport, .. } => {
format!("workdir_session_open_failed: {}", transport.message)
}
Error::RuntimeOperationFailed { code, message, .. } => {
format!("{code}: {}", sanitize_backend_error(message))
}
@@ -32869,6 +32991,8 @@ impl IntoResponse for ApiError {
let status = api_error_status(&self.error);
let response_message = api_error_response_message(&self.error);
let log = ApiErrorLog {
workdir_log: None,
denial_reason: None,
kind: self
.diagnostics
.first()
@@ -33169,6 +33293,7 @@ mod tests {
assert!(matches!(operation, WorkdirSessionOperation::Read(_)));
response
.send(Err(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::NotFound,
message: "logical file was not found".to_string(),
}))
@@ -35167,6 +35292,13 @@ mod tests {
#[derive(Clone, Default)]
struct WorkdirlessFixtureRuntime {
workdir_session_factory: Option<
Arc<
dyn Fn(&str) -> std::result::Result<WorkdirSessionHandle, workdir::WorkdirError>
+ Send
+ Sync,
>,
>,
input_requests: Arc<Mutex<Vec<WorkerInputRequest>>>,
workers: Arc<Mutex<Vec<InternalWorkerSummary>>>,
spawn_requests: Arc<Mutex<Vec<WorkerSpawnRequest>>>,
@@ -35304,6 +35436,27 @@ mod tests {
}
impl crate::hosts::WorkspaceWorkerRuntime for WorkdirlessFixtureRuntime {
fn open_workdir_session<'a>(
&'a self,
working_directory_id: &'a str,
_owner_worker_id: Option<&'a str>,
) -> std::pin::Pin<
Box<
dyn std::future::Future<
Output = std::result::Result<WorkdirSessionHandle, workdir::WorkdirError>,
> + Send
+ 'a,
>,
> {
Box::pin(async move {
self.workdir_session_factory.as_ref().ok_or_else(|| {
workdir::WorkdirError::Unavailable(
"Runtime does not expose Workdir operation sessions".to_string(),
)
})?(working_directory_id)
})
}
fn send_input(
&self,
worker_id: &str,
@@ -36453,6 +36606,8 @@ mod tests {
.parse::<Uri>()
.expect("valid URI");
let error = ApiErrorLog {
workdir_log: None,
denial_reason: None,
kind: "ticket_backend_error".to_string(),
message: "sqlite error: FOREIGN KEY constraint failed".to_string(),
diagnostics: vec![RuntimeDiagnostic {
@@ -36594,6 +36749,7 @@ mod tests {
),
] {
let response = InternalWorkdirOperationError::Provider(WorkdirTransportError {
denial_reason: None,
code,
message: "safe provider message".to_string(),
})
@@ -36613,14 +36769,11 @@ mod tests {
#[tokio::test]
async fn local_and_remote_validation_errors_share_workspace_classification() {
let worker = RuntimeWorkerRef::new("runtime", "worker");
let local = current_worker_workdir_operation_error(
&worker,
workdir::WorkdirError::InvalidGlob("[".to_string()),
);
let remote = current_worker_workdir_operation_error(
&worker,
let local =
workdir_operation_transport_error(workdir::WorkdirError::InvalidGlob("[".to_string()));
let remote = workdir_operation_transport_error(
WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message: "Workdir operation request is invalid".to_string(),
}
@@ -36646,10 +36799,7 @@ mod tests {
path: PathBuf::from("/host/private/worktree/secret.txt"),
source: std::io::Error::other("token=secret"),
};
let public = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"),
error,
);
let public = workdir_operation_transport_error(error);
let response = InternalWorkdirOperationError::Provider(public).into_response();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
let text = String::from_utf8(body.to_vec()).unwrap();
@@ -36658,10 +36808,7 @@ mod tests {
let decoded: WorkdirTransportError = serde_json::from_str(&text).unwrap();
assert_eq!(decoded.code, workdir::http::WorkdirTransportErrorCode::Io);
let remote = current_worker_workdir_operation_error(
&RuntimeWorkerRef::new("runtime", "worker"),
workdir::WorkdirError::OperationFailed,
);
let remote = workdir_operation_transport_error(workdir::WorkdirError::OperationFailed);
let response = InternalWorkdirOperationError::Provider(remote).into_response();
assert_eq!(response.status(), StatusCode::INTERNAL_SERVER_ERROR);
}
@@ -38261,6 +38408,7 @@ mod tests {
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
// Unit fixture stands below proof middleware, so inject its verified subject.
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: fixture.controller.runtime_id.clone(),
worker_id: Some(fixture.controller.worker_id.clone()),
})
@@ -38584,6 +38732,7 @@ mod tests {
.method("POST")
.uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"))
.extension(crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: controller.runtime_id.clone(),
worker_id: Some(controller.worker_id.clone()),
})
@@ -17,6 +17,12 @@ impl Write for CapturedApiLog {
}
async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode, Value, Vec<Value>) {
let token_hash = request
.headers()
.get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER)
.and_then(|value| value.to_str().ok())
.and_then(|proof| worker_runtime::auth::decode_runtime_request_source_claims(proof).ok())
.map(|claims| crate::worker_source::diagnostic_id_hash(&claims.jti));
let logs = CapturedApiLog::default();
let writer = logs.clone();
let subscriber = tracing_subscriber::fmt()
@@ -32,18 +38,41 @@ async fn request_with_logs(app: Router, request: Request<Body>) -> (StatusCode,
.await
.unwrap();
let status = response.status();
assert_eq!(response.headers()[CONTENT_TYPE], "application/json");
let content_type = response.headers()[CONTENT_TYPE].clone();
let body = to_bytes(response.into_body(), usize::MAX).await.unwrap();
assert_eq!(
content_type,
"application/json",
"status={status}, body={}",
String::from_utf8_lossy(&body)
);
let body: Value = serde_json::from_slice(&body).unwrap();
let text = String::from_utf8(logs.0.lock().unwrap().clone()).unwrap();
for secret in ["query-secret", "bearer-secret", "body-secret"] {
for secret in [
"query-secret",
"bearer-secret",
"body-secret",
"/private/provider/path",
"/host/private",
"proof-secret",
"credential-secret",
"key-secret",
] {
assert!(!text.contains(secret), "log leaked request data: {text}");
}
let events = text
let events: Vec<Value> = text
.lines()
.map(|line| serde_json::from_str::<Value>(line).unwrap())
.filter(|event| event["event"] == "api_error")
.collect();
for event in &events {
if event.get("operation").is_some() && token_hash.is_some() {
assert_eq!(
event["request_token_id_hash"],
serde_json::to_value(&token_hash).unwrap()
);
}
}
(status, body, events)
}
@@ -278,17 +307,50 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
)
};
let app = build_router(fixture.api.clone());
for (code, status, message) in [
(Code::Denied, 403, "Workdir operation was denied"),
(Code::OutOfScope, 403, "Workdir path is out of scope"),
for (code, status, message, reason) in [
(Code::Denied, 403, "Workdir operation was denied", None),
(
Code::Denied,
403,
"Workdir operation was denied",
Some(workdir::WorkdirDenialReason::OsPermissionDenied),
),
(
Code::Denied,
403,
"Workdir operation was denied",
Some(workdir::WorkdirDenialReason::ReadOnlySession),
),
(
Code::OutOfScope,
403,
"Workdir path is out of scope",
Some(workdir::WorkdirDenialReason::PathOutOfScope),
),
(
Code::SymlinkOutOfScope,
403,
"Workdir symlink target is out of scope",
Some(workdir::WorkdirDenialReason::SymlinkTargetOutOfScope),
),
(
Code::ReadOnly,
403,
"Workdir path is read-only",
Some(workdir::WorkdirDenialReason::PathReadOnly),
),
(
Code::UnknownCommand,
404,
"Workdir command was not found",
None,
),
(
Code::Unavailable,
503,
"Workdir session is unavailable",
None,
),
(Code::ReadOnly, 403, "Workdir path is read-only"),
(Code::UnknownCommand, 404, "Workdir command was not found"),
(Code::Unavailable, 503, "Workdir session is unavailable"),
] {
let provider = async {
let Some(ExternalProviderCommand::Operation {
@@ -305,6 +367,7 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
));
response
.send(Err(WorkdirTransportError {
denial_reason: reason,
code,
message: "body-secret /private/provider/path".to_string(),
}))
@@ -324,6 +387,22 @@ async fn generated_workdir_provider_errors_preserve_response_and_log_details() {
assert_eq!(events[0]["status"], status);
assert_eq!(events[0]["method"], "POST");
assert_eq!(events[0]["path"], path);
assert_eq!(events[0]["operation"], "command_status");
assert_eq!(events[0]["workdir_stage"], "provider_dispatch");
assert_workdir_log_identity(
&events[0],
&fixture,
"checkout",
Some(&fixture.main_workdir_id),
);
assert_eq!(
events[0]["denial_reason"],
serde_json::to_value(reason).unwrap()
);
assert_eq!(
events[0]["request_token_id_hash"].as_str().unwrap().len(),
64
);
}
let provider = async {
let Some(ExternalProviderCommand::Operation { response, .. }) = commands.recv().await
@@ -374,8 +453,230 @@ async fn generated_workdir_api_errors_preserve_response_and_log_details() {
assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, serde_json::to_value(expected).unwrap());
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["kind"], "forbidden");
assert_eq!(events[0]["message"], body["message"]);
assert_eq!(events[0]["kind"], "workdir_session_operation_api_403");
assert_eq!(
events[0]["message"],
"Workdir operation was rejected before provider dispatch"
);
assert_eq!(events[0]["operation"], "command_status");
assert_eq!(events[0]["workdir_stage"], "worker_identity");
assert_eq!(events[0]["runtime_id_hash"], Value::Null);
assert_eq!(events[0]["worker_id_hash"], Value::Null);
assert_eq!(events[0]["workdir_id_hash"], Value::Null);
assert_eq!(events[0]["diagnostics"], "[]");
assert_eq!(events[0]["path"], path);
}
fn assert_workdir_log_identity(
event: &Value,
fixture: &ManualCoderAssignmentFixture,
alias: &str,
workdir_id: Option<&str>,
) {
let hash = crate::worker_source::diagnostic_id_hash;
assert_eq!(event["workspace_id_hash"], hash(TEST_WORKSPACE_ID));
assert_eq!(event["runtime_id_hash"], hash(&fixture.worker.runtime_id));
assert_eq!(event["worker_id_hash"], hash(&fixture.worker.worker_id));
assert_eq!(event["attachment_alias_hash"], hash(alias));
assert_eq!(
event["workdir_id_hash"],
serde_json::to_value(workdir_id.map(hash)).unwrap()
);
}
#[tokio::test]
async fn self_workdir_provider_refusals_log_operation_identity_without_request_text() {
use workdir::{WorkdirDenialReason as Reason, WorkdirSessionCapabilities as Caps};
let mut fixture = manual_worker_assignment_fixture().await;
let root = tempfile::tempdir().unwrap();
let provider_root = root.path().to_path_buf();
// Fresh sessions exercise the real opening/broker/dispatch path on each request.
fixture.runtime.workdir_session_factory = Some(Arc::new(move |id| {
Ok(Arc::new(workdir::LocalWorkdirSession::materialized_bound(
workdir::Workdir::new(id),
provider_root.clone(),
provider_root.clone(),
manifest::SharedScope::new(manifest::Scope::writable(&provider_root).unwrap()),
Caps::READ_ONLY,
)) as WorkdirSessionHandle)
}));
fixture
.api
.runtime
.register_or_replace(fixture.runtime.clone());
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
.unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let app = build_router(fixture.api.clone());
let secret = format!(
"body-secret credential-secret key-secret proof-secret{}",
"z".repeat(20_000)
);
let cases = [
(
json!({"operation": "read", "request": {"path": "/host/private/secret.txt", "offset": 0, "limit": 10, "max_bytes": 1024}}),
"read",
"out_of_scope",
Reason::PathOutOfScope,
),
(
json!({"operation": "write", "request": {"path": "secret.txt", "content": secret.as_bytes()}}),
"write",
"denied",
Reason::ScopedCapabilityDenied,
),
(
json!({"operation": "command_start", "request": {"command": secret, "timeout_secs": 1, "output_limit": 1024, "cwd": "", "tool_call_id": secret}}),
"command_start",
"denied",
Reason::ScopedCapabilityDenied,
),
(
json!({"operation": "authorize_scope", "request": {"path": "secret.txt", "permission": "write", "rules": [{"target": "", "permission": "write", "recursive": true}]}}),
"authorize_scope",
"denied",
Reason::ScopeResolutionUnavailable,
),
];
for (operation, label, code, reason) in cases {
let _: WorkdirSessionOperation =
serde_json::from_value(operation.clone()).expect("valid operation fixture");
let request = runtime_source_request(
&identity,
Some(&fixture.worker.worker_id),
"POST",
&format!("{path}?token=query-secret"),
serde_json::to_vec(&json!({"target_workdir": "checkout", "operation": operation}))
.unwrap(),
);
let (status, body, events) = request_with_logs(app.clone(), request).await;
assert_eq!(status, StatusCode::FORBIDDEN, "{body}");
assert_eq!(body["code"], code, "{body}");
assert!(
body.get("denial_reason").is_none(),
"internal diagnostics leaked: {body}"
);
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["operation"], label);
assert_eq!(events[0]["workdir_stage"], "provider_dispatch");
assert_eq!(
events[0]["denial_reason"],
serde_json::to_value(reason).unwrap()
);
assert_workdir_log_identity(
&events[0],
&fixture,
"checkout",
Some(&fixture.main_workdir_id),
);
assert_eq!(
events[0]["request_token_id_hash"].as_str().unwrap().len(),
64
);
assert!(
events[0].to_string().len() < 2000,
"unbounded log: {events:?}"
);
}
assert!(
!root.path().join("secret.txt").exists(),
"refusal changed filesystem"
);
}
#[tokio::test]
async fn self_workdir_attachment_rejection_hashes_oversized_alias_and_omits_unknown_workdir() {
let mut fixture = manual_worker_assignment_fixture().await;
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
.unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let alias = format!(
"body-secret /host/private credential-secret key-secret proof-secret{}",
"あ".repeat(10_000)
);
let request = runtime_source_request(
&identity,
Some(&fixture.worker.worker_id),
"POST",
&path,
serde_json::to_vec(&json!({
"target_workdir": alias,
"operation": {"operation": "command_status", "request": "body-secret"}
}))
.unwrap(),
);
let (status, body, events) =
request_with_logs(build_router(fixture.api.clone()), request).await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["workdir_stage"], "attachment_validation");
assert_eq!(events[0]["operation"], "command_status");
assert_workdir_log_identity(&events[0], &fixture, &alias, None);
assert_eq!(events[0]["denial_reason"], Value::Null);
assert!(
events[0].to_string().len() < 2000,
"unbounded log: {events:?}"
);
}
#[tokio::test]
async fn self_workdir_session_open_refusals_keep_reason_before_registry_conversion() {
use workdir::WorkdirDenialReason as Reason;
for (reason, os_error) in [
(Reason::ReadOnlySession, false),
(Reason::OsPermissionDenied, true),
] {
let mut fixture = manual_worker_assignment_fixture().await;
fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| {
Err(if os_error {
workdir::WorkdirError::Io {
path: PathBuf::from("/host/private/key-secret"),
source: std::io::Error::from_raw_os_error(13),
}
} else {
workdir::WorkdirError::denied(reason, "body-secret credential-secret /host/private")
})
}));
fixture
.api
.runtime
.register_or_replace(fixture.runtime.clone());
let identity =
worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id)
.unwrap();
configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations");
let request = runtime_source_request(&identity, Some(&fixture.worker.worker_id), "POST", &path, serde_json::to_vec(&json!({
"target_workdir": "checkout",
"operation": {"operation": "read", "request": {"path": "README.md", "offset": 0, "limit": 10, "max_bytes": 1024}}
})).unwrap());
let (status, body, events) =
request_with_logs(build_router(fixture.api.clone()), request).await;
// Opening retained its existing Registry HTTP meaning, not an operation's 403.
assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}");
assert_eq!(
body["message"],
"workdir_session_open_failed: Workdir operation was denied"
);
assert!(body.get("denial_reason").is_none(), "{body}");
assert_eq!(events.len(), 1, "{events:?}");
assert_eq!(events[0]["workdir_stage"], "session_open");
assert_eq!(events[0]["operation"], "read");
assert_eq!(
events[0]["denial_reason"],
serde_json::to_value(reason).unwrap()
);
assert_workdir_log_identity(
&events[0],
&fixture,
"checkout",
Some(&fixture.main_workdir_id),
);
assert!(!body.to_string().contains("body-secret"));
assert!(!body.to_string().contains("/host/private"));
}
}
@@ -718,6 +718,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay(
let app = generated_workspace_contract_router(ServerApiContractService::Workspace(api.clone()))
.layer(Extension(
crate::worker_source::VerifiedRuntimeRequestSource {
token_id_hash: None,
runtime_id: workers[0].runtime_id.clone(),
worker_id: Some(workers[0].worker_id.clone()),
},
@@ -12,6 +12,8 @@ use crate::store::ControlPlaneStore;
pub struct VerifiedRuntimeRequestSource {
pub runtime_id: String,
pub worker_id: Option<String>,
/// Existing proof JTI correlation, retained only as a fixed-length diagnostic fingerprint.
pub token_id_hash: Option<String>,
}
/// Safe diagnostics only: never stores proof, raw claims, keys or store errors.
@@ -242,6 +244,7 @@ pub async fn verify_runtime_request_source_proof_with_store(
})?;
}
Ok(VerifiedRuntimeRequestSource {
token_id_hash: Some(diagnostic_id_hash(&claims.jti)),
runtime_id: claims.iss,
worker_id: claims.worker_id,
})
+2
View File
@@ -836,6 +836,7 @@ where
.unwrap_or_else(|_| ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(
WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::Internal,
message: "External Workdir operation executor failed".to_string(),
},
@@ -953,6 +954,7 @@ async fn execute_operation(
Ok(result) => ExternalWorkdirOperationOutcome::Completed { result },
Err(message) => ExternalWorkdirOperationOutcome::Failed {
error: ExternalWorkdirOperationError::from_transport_error(WorkdirTransportError {
denial_reason: None,
code: workdir::http::WorkdirTransportErrorCode::InvalidRequest,
message,
}),
@@ -0,0 +1,131 @@
# Workdir refusal diagnostics
This change improves the internal `api_error` record for
`POST /api/w/{workspace_id}/workers/self/workdir-session/operations`.
It does **not** identify or resolve the reported 2026-10-09 403: the running
binary revision and actual request are still unverified. Fixture success is
not evidence about that incident. No deployment, live asset cleanup, privilege
change, retry policy, or WIP dependency/version change is included (WIP remains
0.2.0).
## Diagnostic path and public contract
Original refusing branches attach a closed `WorkdirDenialReason`, rather than
recovering meaning from an error string. `WorkdirError::Denied` carries local
message plus optional typed reason. `DenialContext` retains an existing error
classification while enriching its diagnostics (notably checkout's historical
permission-to-out-of-scope mapping). Typed provider markers survive the
`FsAccessPolicy`/`io::Error` → `FsError` → `WorkdirError` boundaries.
`WorkdirTransportError.denial_reason` and
`ExternalWorkdirOperationError.denial_reason` carry only an optional snake-case
enum. Remote HTTP and External errors preserve that reason when converted back
to `WorkdirError` and then forwarded again. Provider text is not the reason.
The External codec continues dropping provider-authored message text. A missing
reason stays unknown for generic `denied`; existing code-level path/symlink/
read-only classifications can still identify their original category. Unknown
or oversized reason strings fail deserialization rather than becoming log data.
Runtime registry session-open failures now retain a safe transport error **before**
the former raw error-to-string conversion. Their existing Workspace HTTP meaning
is still 502 (`workdir_session_open_failed`); operation-level permission failures
remain 403. Session-open messages are normalized to the same safe provider
message vocabulary instead of forwarding raw local errors. No authorization,
scope, capability, session ownership, command ownership, or retry conditions
are relaxed.
The self handler attaches internal context to its error. Both the handwritten
response adapter and the generated route preserve it through the typed error
response extension to `ApiErrorLog` and the logging middleware. Server API
`workdir_log` and `denial_reason` are `serde(skip)` and omitted from schemas:
the external error envelope remains `{code, message}` for provider failures or
the existing Repository-style envelope for API/domain failures. Workspace
responses do not expose the new internal diagnostics. Authorized Runtime/provider
transport carries the enum to its consumer; it contains no free text or paths.
## Fields in `yoi::api` / `api_error`
- `operation`: fixed label from the typed operation (read, read_bytes, write,
edit, command_start/status/output/cancel, authorize_scope, scope_rules_overlap,
checkout_search/observe/execute, stat/list/glob/grep). No operation Debug dump.
- `workdir_stage`: workspace_scope, worker_identity, attachment_validation,
session_open, command_lookup, or provider_dispatch. API failures identify the
refusing stage without copying raw alias/handle/error text into the log.
- `denial_reason`: optional closed reason reported by the provider/error chain.
It describes the encountered refusing branch, not a permission grant and not
an independent attestation of an external provider's explanation.
- `workspace_id_hash`, `runtime_id_hash`, `worker_id_hash`, `workdir_id_hash`,
`attachment_alias_hash`: 64-character lowercase SHA-256 fingerprints via
`worker_source::diagnostic_id_hash`, using the existing
`yoi-runtime-proof-diagnostic-id-v1\0` domain prefix. Hash a known catalog ID
or attachment alias to locate its logs. Workspace and alias represent route/
request selection; Runtime/Worker are added only after current identity checks,
and Workdir only after a validated attachment is found. Missing information is
omitted, never inferred from a failed lookup or command handle.
- `request_token_id_hash`: fingerprint of the **existing verified Runtime proof
JTI**, reusing the T-729 hash algorithm. It is not the proof, raw token ID,
caller-supplied trace header, or a newly invented cross-service trace ID.
In-process/test callers without that proof correlation omit it. Authentication
still consumes the JTI using the existing policy; logging does not retry it.
- Existing `method`, query-free `path`, `status`, `kind`, `message`, `diagnostics`
remain. Workdir-context method/path are bounded to 32/512 UTF-8 bytes with
controls removed. Provider operation messages remain normalized safe text.
Contextual API failures use a fixed internal message and status-based kind,
with no raw domain diagnostics copied into this log. Empty `diagnostics` does
**not** mean the optional typed reason/context is absent.
Fingerprints are bounded correlation, not encryption: low-entropy identifiers
can be guessed. Do not put credentials in IDs. Neither hashes nor stage labels
prove the reported incident's cause. The request JTI identifies the incoming
request; External operation IDs/generation/audit records remain their existing
provider-side mechanisms, not a cross-service trace automatically joined here.
## Refusal vocabulary and limits
Reasons distinguish scope resolution/comparison unavailable, attenuated
capability refusal, logical/parent/attachment/delegation scope exceeded,
empty scope, invalid scoped path, writable-scope requirements for commands,
child write-lease conflicts, unreadable cwd, read-only session, external root
symlink/change/move, external scope symlink, checkout output-root escape,
provider root escape, checkout search/enumeration refusal, provider symlink/
checked-target refusal, path out of scope, symlink target out of scope, and
read-only path. Original local messages remain local; arbitrary message text
is never parsed into a reason.
- `os_permission_denied`: PermissionDenied with an actual OS error code, without
a recognized policy marker. It does not determine whether modes, ACLs,
sandboxing, mount policy, or another OS mechanism caused the refusal.
- `unclassified_permission_denied`: custom PermissionDenied with neither typed
policy origin nor raw OS code. Do not label an arbitrary
`Error::new(PermissionDenied, "...")` as an OS refusal by guessing from text.
- External/remote providers that do not send a reason cannot supply distinctions
erased in their own binary. A registered provider's typed explanation is
reported data, not proof of what happened inside that provider.
- Authentication/JSON ingress failures before a typed Workdir operation and
target can be obtained continue using their existing rejection diagnostics;
no operation/body is guessed or decoded from rejected proof data.
## Regression proof boundaries
- Workdir provider fixtures exercise actual local authorization, broker scope/
capability/read-only/delegation/lease refusals and confinement. Permission
fixtures distinguish typed policy origins, OS-coded PermissionDenied, and
origin-unknown custom errors, including checkout's existing public mapping.
- Loopback HTTP fake exercises remote open/operation decoding and typed reason
forwarding, including missing-reason providers and hostile provider text.
External JSON/frame codecs exercise the bounded error envelope and reject
arbitrary reason strings; the Server's External adapter drives the real self
generated route through command-session dispatch and response/log conversion.
- Server router tests use signed fixture requests and capture actual tracing
events. They cover read/write/command_start/authorize_scope, session-open
failures, External command status failures, no-identity rejection, oversized
UTF-8 alias, oversized command/file/tool-call inputs, query/header/proof/key/
credential markers, and raw provider/host paths. Identity hashes and the
existing verified JTI fingerprint are checked against fixture identities.
- The Server API boundary proves internal fields are absent from body/schema.
Worker HTTP→WIP coverage verifies diagnostic wrappers do not change refusal
classification or induce retries; tools retain established checkout codes.
These are hermetic Rust/API/protocol fixtures, not a full product-process E2E,
not a deployed binary test, and not reproduction of the user's asset/request.
Validation commands and concrete counts are recorded on the implementation MR.