fix: enforce hierarchical external Workdir permissions

This commit is contained in:
2026-09-30 02:01:12 +09:00
parent 677c7cfed9
commit 39ecd1b239
17 changed files with 510 additions and 243 deletions
+53 -9
View File
@@ -5262,27 +5262,71 @@ impl From<workdir::workspace::WorkingDirectorySummary> for WorkingDirectorySumma
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ExternalWorkdirPermissionLevel {
Read,
Write,
Command,
}
impl ExternalWorkdirPermissionLevel {
pub const fn permissions(self) -> ExternalWorkdirPermissions {
match self {
Self::Read => ExternalWorkdirPermissions::READ,
Self::Write => ExternalWorkdirPermissions::WRITE,
Self::Command => ExternalWorkdirPermissions::COMMAND,
}
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
pub struct ExternalWorkdirPermissions { pub struct ExternalWorkdirPermissions {
/// Read files and use read-only discovery operations such as Glob and Grep. /// Read files and use read-only discovery operations such as Glob and Grep.
/// Every supported External Workdir permission level enables this field.
pub read: bool, pub read: bool,
/// Create or update files with Write and Edit. Existing-file mutation also /// Create or update files with Write and Edit. WRITE includes READ so callers
/// requires `read` so callers can satisfy the read-before-write contract. /// can satisfy the read-before-write contract.
pub write: bool, pub write: bool,
/// Execute commands as the sharing CLI user on the provider host. This is /// Execute commands as the sharing CLI user on the provider host. COMMAND
/// explicit authority and does not imply either filesystem category. /// includes WRITE and READ, remains explicit, and is not sandboxed.
pub command: bool, pub command: bool,
} }
impl ExternalWorkdirPermissions {
pub const READ: Self = Self {
read: true,
write: false,
command: false,
};
pub const WRITE: Self = Self {
read: true,
write: true,
command: false,
};
pub const COMMAND: Self = Self {
read: true,
write: true,
command: true,
};
/// Returns the single hierarchical level represented by a grant permission
/// object. Partial combinations remain valid for attachment/session
/// projections, but are not valid authority for creating an External grant.
pub const fn grant_level(self) -> Option<ExternalWorkdirPermissionLevel> {
match (self.read, self.write, self.command) {
(true, false, false) => Some(ExternalWorkdirPermissionLevel::Read),
(true, true, false) => Some(ExternalWorkdirPermissionLevel::Write),
(true, true, true) => Some(ExternalWorkdirPermissionLevel::Command),
_ => None,
}
}
}
impl Default for ExternalWorkdirPermissions { impl Default for ExternalWorkdirPermissions {
fn default() -> Self { fn default() -> Self {
Self { Self::READ
read: true,
write: false,
command: false,
}
} }
} }
+229 -104
View File
@@ -3905,72 +3905,47 @@ struct ValidatedTicketImplementationTarget {
} }
fn external_workdir_permissions(persisted: &str) -> Result<server_api::ExternalWorkdirPermissions> { fn external_workdir_permissions(persisted: &str) -> Result<server_api::ExternalWorkdirPermissions> {
let permissions = match persisted { match persisted {
"read_only" => server_api::ExternalWorkdirPermissions { "read_only" => Ok(server_api::ExternalWorkdirPermissions::READ),
read: true, "read_write" => Ok(server_api::ExternalWorkdirPermissions::WRITE),
write: false, "read_write_command" => Ok(server_api::ExternalWorkdirPermissions::COMMAND),
command: false, "command_only" | "read_command" => Err(Error::InvalidInput(
}, "External Workdir grant uses a legacy non-hierarchical permission combination; revoke it and share the directory again with READ, WRITE, or COMMAND"
"read_write" => server_api::ExternalWorkdirPermissions { .to_string(),
read: true, )),
write: true, _ => Err(Error::Store(
command: false, "External Workdir grant has invalid persisted permissions".to_string(),
}, )),
"command_only" => server_api::ExternalWorkdirPermissions { }
read: false,
write: false,
command: true,
},
"read_command" => server_api::ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
"read_write_command" => server_api::ExternalWorkdirPermissions {
read: true,
write: true,
command: true,
},
_ => {
return Err(Error::Store(
"External Workdir grant has invalid persisted permissions".to_string(),
));
}
};
Ok(permissions)
} }
fn persist_external_workdir_permissions( fn persist_external_workdir_permissions(
permissions: server_api::ExternalWorkdirPermissions, permissions: server_api::ExternalWorkdirPermissions,
) -> Result<&'static str> { ) -> Result<&'static str> {
match (permissions.read, permissions.write, permissions.command) { match permissions.grant_level() {
(true, false, false) => Ok("read_only"), Some(server_api::ExternalWorkdirPermissionLevel::Read) => Ok("read_only"),
(true, true, false) => Ok("read_write"), Some(server_api::ExternalWorkdirPermissionLevel::Write) => Ok("read_write"),
(false, false, true) => Ok("command_only"), Some(server_api::ExternalWorkdirPermissionLevel::Command) => Ok("read_write_command"),
(true, false, true) => Ok("read_command"), None => Err(Error::InvalidInput(
(true, true, true) => Ok("read_write_command"), "External Workdir permissions must select exactly one level: READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)"
(false, true, _) => Err(Error::InvalidInput( .to_string(),
"External Workdir WRITE permission requires READ for read-before-write".to_string(),
)),
(false, false, false) => Err(Error::InvalidInput(
"External Workdir sharing requires at least one of READ or COMMAND".to_string(),
)), )),
} }
} }
fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> { fn external_workdir_capabilities(permissions: &str) -> Result<workdir::WorkdirSessionCapabilities> {
let permissions = external_workdir_permissions(permissions)?; match external_workdir_permissions(permissions)?.grant_level() {
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY; Some(server_api::ExternalWorkdirPermissionLevel::Read) => {
if permissions.read { Ok(workdir::WorkdirSessionCapabilities::READ_ONLY)
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY); }
Some(server_api::ExternalWorkdirPermissionLevel::Write) => {
Ok(workdir::WorkdirSessionCapabilities::READ_WRITE)
}
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
Ok(workdir::WorkdirSessionCapabilities::ALL)
}
None => unreachable!("persisted External grant permissions were already validated"),
} }
if permissions.write {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
}
if permissions.command {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
}
Ok(capabilities)
} }
fn workdir_permission_summary( fn workdir_permission_summary(
@@ -26480,36 +26455,49 @@ mod tests {
}; };
#[test] #[test]
fn external_permission_categories_map_to_existing_capability_bits_without_escalation() { fn external_permission_levels_map_to_existing_bits_and_reject_legacy_partial_grants() {
let bits = |capabilities| {
serde_json::to_value(capabilities).unwrap()["bits"]
.as_u64()
.unwrap()
};
assert_eq!( assert_eq!(
external_workdir_capabilities("read_only").unwrap(), bits(external_workdir_capabilities("read_only").unwrap()),
workdir::WorkdirSessionCapabilities::READ_ONLY 25
); );
assert_eq!( assert_eq!(
external_workdir_capabilities("read_write").unwrap(), bits(external_workdir_capabilities("read_write").unwrap()),
workdir::WorkdirSessionCapabilities::READ_WRITE 31
); );
assert_eq!( assert_eq!(
external_workdir_capabilities("command_only").unwrap(), bits(external_workdir_capabilities("read_write_command").unwrap()),
workdir::WorkdirSessionCapabilities::COMMAND_ONLY 63
); );
assert_eq!(
external_workdir_capabilities("read_command").unwrap(), for legacy in ["command_only", "read_command"] {
workdir::WorkdirSessionCapabilities::READ_ONLY let error = external_workdir_capabilities(legacy).unwrap_err();
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY) assert!(error.to_string().contains("revoke it and share"));
); }
assert_eq!( for invalid in [
external_workdir_capabilities("read_write_command").unwrap(), server_api::ExternalWorkdirPermissions {
workdir::WorkdirSessionCapabilities::ALL read: false,
); write: false,
assert!( command: true,
persist_external_workdir_permissions(server_api::ExternalWorkdirPermissions { },
server_api::ExternalWorkdirPermissions {
read: false, read: false,
write: true, write: true,
command: false, command: false,
}) },
.is_err() server_api::ExternalWorkdirPermissions {
); read: true,
write: false,
command: true,
},
] {
let error = persist_external_workdir_permissions(invalid).unwrap_err();
assert!(error.to_string().contains("exactly one level"));
}
} }
#[test] #[test]
@@ -26736,7 +26724,41 @@ mod tests {
} }
#[tokio::test] #[tokio::test]
async fn external_provider_command_only_session_denies_read_and_routes_bounded_lifecycle() { async fn external_provider_write_level_denies_command_without_provider_fallback() {
let (sender, mut commands) = tokio::sync::mpsc::channel(1);
let connection = Arc::new(ExternalProviderConnection {
grant_id: "grant-write".to_string(),
workdir_id: "external-write".to_string(),
provider_instance_id: "provider-write".to_string(),
generation: 1,
expires_at: None,
capabilities: workdir::WorkdirSessionCapabilities::READ_WRITE,
admission: Arc::new(tokio::sync::Semaphore::new(16)),
shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)),
sender,
});
let session = ExternalProviderWorkdirSession::new(connection, None);
let denied = session
.start_command(workdir::CommandRequest {
command: "printf forbidden".to_string(),
timeout_secs: 30,
output_limit: 1024,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await;
assert!(matches!(
denied,
Err(workdir::WorkdirError::Unsupported(
workdir::WorkdirSessionCapability::Command
))
));
assert!(commands.try_recv().is_err());
}
#[tokio::test]
async fn external_provider_partial_session_capabilities_remain_supported() {
let (sender, mut commands) = tokio::sync::mpsc::channel(4); let (sender, mut commands) = tokio::sync::mpsc::channel(4);
let capabilities = workdir::WorkdirSessionCapabilities::COMMAND_ONLY; let capabilities = workdir::WorkdirSessionCapabilities::COMMAND_ONLY;
let connection = Arc::new(ExternalProviderConnection { let connection = Arc::new(ExternalProviderConnection {
@@ -27445,6 +27467,105 @@ mod tests {
assert!(audit_actions.contains(&"external_workdir_cleanup_completed".to_string())); assert!(audit_actions.contains(&"external_workdir_cleanup_completed".to_string()));
} }
#[tokio::test]
async fn external_grant_api_persists_only_hierarchical_levels_with_exact_bits() {
let dir = tempfile::tempdir().unwrap();
init_clean_git_workspace(dir.path());
let api = test_api(dir.path()).await;
let levels = [
(
server_api::ExternalWorkdirPermissions::READ,
"read_only",
25,
),
(
server_api::ExternalWorkdirPermissions::WRITE,
"read_write",
31,
),
(
server_api::ExternalWorkdirPermissions::COMMAND,
"read_write_command",
63,
),
];
for (index, (permissions, persisted, expected_bits)) in levels.into_iter().enumerate() {
let (_, Json(grant)) = scoped_create_external_workdir_grant(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Extension(test_browser_request_actor()),
Json(ExternalWorkdirGrantCreateRequest {
provider_instance_id: format!("provider-{index}"),
display_name: format!("Level {index}"),
ttl_seconds: None,
permissions,
}),
)
.await
.unwrap();
assert_eq!(grant.permissions, permissions);
let stored = api
.store
.get_external_workdir_grant(TEST_WORKSPACE_ID, &grant.grant_id)
.unwrap()
.unwrap();
assert_eq!(stored.permissions, persisted);
let bits =
serde_json::to_value(external_workdir_capabilities(&stored.permissions).unwrap())
.unwrap()["bits"]
.as_u64()
.unwrap();
assert_eq!(bits, expected_bits);
}
for (index, permissions) in [
server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
},
server_api::ExternalWorkdirPermissions {
read: false,
write: true,
command: false,
},
server_api::ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
]
.into_iter()
.enumerate()
{
let error = scoped_create_external_workdir_grant(
State(api.clone()),
AxumPath(ScopedWorkspacePath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
}),
Extension(test_browser_request_actor()),
Json(ExternalWorkdirGrantCreateRequest {
provider_instance_id: format!("invalid-provider-{index}"),
display_name: format!("Invalid {index}"),
ttl_seconds: None,
permissions,
}),
)
.await
.unwrap_err();
assert!(error.error.to_string().contains("exactly one level"));
}
assert_eq!(
api.store
.list_external_workdir_grants(TEST_WORKSPACE_ID)
.unwrap()
.len(),
3
);
}
#[tokio::test] #[tokio::test]
async fn external_workdirs_are_launchable_with_real_multiple_attachments() { async fn external_workdirs_are_launchable_with_real_multiple_attachments() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -27460,15 +27581,11 @@ mod tests {
for index in 1..=2 { for index in 1..=2 {
let (permissions, capabilities) = if index == 1 { let (permissions, capabilities) = if index == 1 {
( ("read_only", workdir::WorkdirSessionCapabilities::READ_ONLY)
"command_only",
workdir::WorkdirSessionCapabilities::COMMAND_ONLY,
)
} else { } else {
( (
"read_command", "read_write_command",
workdir::WorkdirSessionCapabilities::READ_ONLY workdir::WorkdirSessionCapabilities::ALL,
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY),
) )
}; };
let grant = ExternalWorkdirGrantRecord { let grant = ExternalWorkdirGrantRecord {
@@ -27557,17 +27674,9 @@ mod tests {
.find(|summary| summary.working_directory_id == *workdir_id) .find(|summary| summary.working_directory_id == *workdir_id)
.unwrap(); .unwrap();
let expected_permissions = if index == 0 { let expected_permissions = if index == 0 {
server_api::ExternalWorkdirPermissions { server_api::ExternalWorkdirPermissions::READ
read: false,
write: false,
command: true,
}
} else { } else {
server_api::ExternalWorkdirPermissions { server_api::ExternalWorkdirPermissions::COMMAND
read: true,
write: false,
command: true,
}
}; };
assert!(matches!( assert!(matches!(
&summary.source, &summary.source,
@@ -27628,10 +27737,9 @@ mod tests {
.unwrap(); .unwrap();
for (index, receiver) in receivers.iter_mut().enumerate() { for (index, receiver) in receivers.iter_mut().enumerate() {
let expected_capabilities = if index == 0 { let expected_capabilities = if index == 0 {
workdir::WorkdirSessionCapabilities::COMMAND_ONLY
} else {
workdir::WorkdirSessionCapabilities::READ_ONLY workdir::WorkdirSessionCapabilities::READ_ONLY
.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY) } else {
workdir::WorkdirSessionCapabilities::ALL
}; };
let link = links let link = links
.iter() .iter()
@@ -27658,20 +27766,37 @@ mod tests {
drop(session_guard); drop(session_guard);
assert_eq!(session.capabilities(), expected_capabilities); assert_eq!(session.capabilities(), expected_capabilities);
if index == 0 { if index == 0 {
let denied = session let denied_write = session
.read(workdir::ReadRequest { .write(workdir::WriteRequest {
path: workdir::WorkdirPath::new("forbidden.txt").unwrap(), path: workdir::WorkdirPath::new("forbidden.txt").unwrap(),
offset: 0, content: b"no fallback".to_vec(),
limit: 1, expected_hash: None,
max_bytes: 1,
}) })
.await; .await;
assert!(matches!( assert!(matches!(
denied, denied_write,
Err(workdir::WorkdirError::Unsupported( Err(workdir::WorkdirError::Unsupported(
workdir::WorkdirSessionCapability::Read workdir::WorkdirSessionCapability::Write
)) | Err(workdir::WorkdirError::Denied(_)) )) | Err(workdir::WorkdirError::Denied(_))
)); ));
let denied_command = session
.start_command(workdir::CommandRequest {
command: "printf forbidden".to_string(),
timeout_secs: 30,
output_limit: 1024,
cwd: workdir::WorkdirPath::root(),
spill_dir: None,
tool_call_id: None,
})
.await;
assert!(matches!(
denied_command,
Err(workdir::WorkdirError::Unsupported(
workdir::WorkdirSessionCapability::Command
)) | Err(workdir::WorkdirError::Denied(_))
));
assert!(receiver.try_recv().is_err());
continue;
} }
let mut starter = { let mut starter = {
+30 -42
View File
@@ -623,7 +623,7 @@ fn parse_workdir_args(
let mut ttl = None; let mut ttl = None;
let mut read_only = false; let mut read_only = false;
let mut read_write = false; let mut read_write = false;
let mut explicit_permissions: Option<server_api::ExternalWorkdirPermissions> = None; let mut explicit_permission_level = None;
let mut non_interactive = false; let mut non_interactive = false;
let mut index = 0; let mut index = 0;
while index < args.len() { while index < args.len() {
@@ -668,29 +668,22 @@ fn parse_workdir_args(
index += 2; index += 2;
} }
"--permission" => { "--permission" => {
let category = required_option_value(args, index, "--permission")?; let level = required_option_value(args, index, "--permission")?;
let permissions = if explicit_permission_level.is_some() {
explicit_permissions.get_or_insert(server_api::ExternalWorkdirPermissions { return Err(ParseError(
read: false, "--permission selects one level and may only be provided once".to_string(),
write: false, ));
command: false, }
}); explicit_permission_level = Some(match level.to_ascii_lowercase().as_str() {
let enabled = match category.to_ascii_lowercase().as_str() { "read" => server_api::ExternalWorkdirPermissionLevel::Read,
"read" => &mut permissions.read, "write" => server_api::ExternalWorkdirPermissionLevel::Write,
"write" => &mut permissions.write, "command" => server_api::ExternalWorkdirPermissionLevel::Command,
"command" => &mut permissions.command,
_ => { _ => {
return Err(ParseError(format!( return Err(ParseError(format!(
"unknown --permission category `{category}`; expected read, write, or command" "unknown --permission level `{level}`; expected read, write, or command"
))); )));
} }
}; });
if *enabled {
return Err(ParseError(format!(
"--permission {category} may only be provided once"
)));
}
*enabled = true;
index += 2; index += 2;
} }
"--read-only" => { "--read-only" => {
@@ -742,21 +735,18 @@ fn parse_workdir_args(
"--read-only conflicts with --read-write".to_string(), "--read-only conflicts with --read-write".to_string(),
)); ));
} }
if explicit_permissions.is_some() && (read_only || read_write) { if explicit_permission_level.is_some() && (read_only || read_write) {
return Err(ParseError( return Err(ParseError(
"--permission conflicts with legacy --read-only/--read-write".to_string(), "--permission conflicts with legacy --read-only/--read-write".to_string(),
)); ));
} }
let permissions = explicit_permissions.unwrap_or(server_api::ExternalWorkdirPermissions { let permissions = explicit_permission_level
read: true, .map(server_api::ExternalWorkdirPermissionLevel::permissions)
write: read_write, .unwrap_or(if read_write {
command: false, server_api::ExternalWorkdirPermissions::WRITE
}); } else {
if permissions.write && !permissions.read { server_api::ExternalWorkdirPermissions::READ
return Err(ParseError( });
"--permission write requires --permission read for read-before-write".to_string(),
));
}
if non_interactive && workspace_id.is_none() { if non_interactive && workspace_id.is_none() {
return Err(ParseError( return Err(ParseError(
"--non-interactive requires --workspace-id".to_string(), "--non-interactive requires --workspace-id".to_string(),
@@ -1900,7 +1890,7 @@ Usage:
yoi --backend <URL> [--workspace-id <ID>] panel yoi --backend <URL> [--workspace-id <ID>] panel
yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>] yoi [--backend <URL>] init --display-name <NAME> --repository-key <KEY> [--repository <PATH>] [--default-ref <REF>]
yoi [--backend <URL>] login [--no-wait] yoi [--backend <URL>] login [--no-wait]
yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>]... [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive] yoi [--backend <URL>] workdir share <PATH> [--workspace-id <ID>] [--permission <read|write|command>] [--read-only|--read-write] [--ttl <TTL>] [--display-name <NAME>] [--non-interactive]
yoi <HOST_COMMAND> [OPTIONS] yoi <HOST_COMMAND> [OPTIONS]
Target selection: Target selection:
@@ -1946,7 +1936,7 @@ Options:
fn print_workdir_help() { fn print_workdir_help() {
println!( println!(
"yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, READ/WRITE/COMMAND categories, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE enables Write/Edit and requires READ for read-before-write. COMMAND is independent, disabled by default, and runs with the sharing CLI user's host authority; it is not sandboxed.\nRepeat --permission to choose explicit categories in automation. Legacy --read-only and --read-write remain file-only aliases and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <CATEGORY> Enable read, write, or command; repeat per category\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for read + write (commands remain disabled)\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n" "yoi workdir share\n\nUsage:\n yoi [--backend <URL>] workdir share <PATH> [OPTIONS]\n\nThe foreground CLI provides one local directory over an authenticated outbound Backend connection.\nInteractive mode is the default: choose an accessible Workspace, one READ/WRITE/COMMAND permission level, and TTL, then explicitly confirm before any grant is created.\nREAD enables Read/Glob/Grep and related observation operations. WRITE includes READ and adds Write/Edit. COMMAND includes WRITE and READ, adds command start/status/output/cancel, and runs with the sharing CLI user's host authority; it is not sandboxed.\nUse --permission once to choose the level in automation. Legacy --read-only and --read-write retain the READ and WRITE meanings and conflict with --permission.\nOmitting --ttl means unlimited automatic lifetime, but stop, disconnect, Ctrl-C, and revoke still fail closed. TTL accepts s, m, or h suffixes from 60s through 24h.\nNo host path is sent to the Backend. Ctrl-C explicitly revokes the grant before exit.\n\nOptions:\n --workspace-id <ID> Preselect a Workspace; required with --non-interactive\n --permission <LEVEL> Grant read, write (includes read), or command (includes write + read)\n --read-only Legacy alias for --permission read\n --read-write Legacy alias for --permission write\n --ttl <TTL> Automatic expiry; omission is unlimited\n --display-name <NAME> Backend-visible label (default: External Workdir)\n --non-interactive Never prompt; requires --workspace-id\n -h, --help Print help\n"
); );
} }
@@ -2038,7 +2028,7 @@ mod tests {
} }
#[test] #[test]
fn workdir_share_parses_independent_command_permission() { fn workdir_share_parses_command_as_the_full_hierarchical_level() {
let args = vec![ let args = vec![
"share".to_string(), "share".to_string(),
".".to_string(), ".".to_string(),
@@ -2056,11 +2046,7 @@ mod tests {
}; };
assert_eq!( assert_eq!(
options.permissions, options.permissions,
server_api::ExternalWorkdirPermissions { server_api::ExternalWorkdirPermissions::COMMAND
read: false,
write: false,
command: true,
}
); );
} }
@@ -2131,17 +2117,19 @@ mod tests {
.contains("conflicts") .contains("conflicts")
); );
let write_without_read = vec![ let multiple_levels = vec![
"share".to_string(), "share".to_string(),
".".to_string(), ".".to_string(),
"--permission".to_string(), "--permission".to_string(),
"write".to_string(), "write".to_string(),
"--permission".to_string(),
"command".to_string(),
]; ];
assert!( assert!(
parse_workdir_args(&write_without_read, &target) parse_workdir_args(&multiple_levels, &target)
.unwrap_err() .unwrap_err()
.0 .0
.contains("requires --permission read") .contains("selects one level")
); );
let missing_workspace = TargetSelection { let missing_workspace = TargetSelection {
+71 -52
View File
@@ -140,47 +140,42 @@ fn authenticated_server_api_client(
Ok(ServerApiClient::with_client(base_url, http_client)) Ok(ServerApiClient::with_client(base_url, http_client))
} }
fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> String { fn permissions_label(permissions: server_api::ExternalWorkdirPermissions) -> &'static str {
let mut enabled = Vec::new(); match permissions.grant_level() {
if permissions.read { Some(server_api::ExternalWorkdirPermissionLevel::Read) => "READ",
enabled.push("READ"); Some(server_api::ExternalWorkdirPermissionLevel::Write) => "WRITE (includes READ)",
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
"COMMAND (includes WRITE + READ)"
}
None => "INVALID",
} }
if permissions.write {
enabled.push("WRITE");
}
if permissions.command {
enabled.push("COMMAND");
}
enabled.join(", ")
} }
fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> { fn validate_permissions(permissions: server_api::ExternalWorkdirPermissions) -> Result<(), String> {
if permissions.write && !permissions.read { if permissions.grant_level().is_none() {
return Err( return Err(
"WRITE requires READ so existing files can satisfy read-before-write; select both categories" "select exactly one permission level: READ, WRITE (includes READ), or COMMAND (includes WRITE + READ)"
.to_string(), .to_string(),
); );
} }
if !permissions.read && !permissions.command {
return Err("select at least one of READ or COMMAND".to_string());
}
Ok(()) Ok(())
} }
fn workdir_capabilities( fn workdir_capabilities(
permissions: server_api::ExternalWorkdirPermissions, permissions: server_api::ExternalWorkdirPermissions,
) -> workdir::WorkdirSessionCapabilities { ) -> workdir::WorkdirSessionCapabilities {
let mut capabilities = workdir::WorkdirSessionCapabilities::EMPTY; match permissions.grant_level() {
if permissions.read { Some(server_api::ExternalWorkdirPermissionLevel::Read) => {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::READ_ONLY); workdir::WorkdirSessionCapabilities::READ_ONLY
}
Some(server_api::ExternalWorkdirPermissionLevel::Write) => {
workdir::WorkdirSessionCapabilities::READ_WRITE
}
Some(server_api::ExternalWorkdirPermissionLevel::Command) => {
workdir::WorkdirSessionCapabilities::ALL
}
None => workdir::WorkdirSessionCapabilities::EMPTY,
} }
if permissions.write {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::WRITE_ONLY);
}
if permissions.command {
capabilities = capabilities.union(workdir::WorkdirSessionCapabilities::COMMAND_ONLY);
}
capabilities
} }
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
@@ -302,14 +297,14 @@ fn configure_interactively(
writeln!( writeln!(
output, output,
"Permissions are independent categories: READ (Read/Glob/Grep), WRITE (Write/Edit), and COMMAND (host command lifecycle). WRITE requires READ for read-before-write. COMMAND runs with this CLI user's host authority and is not sandboxed." "Permission levels: READ (Read/Glob/Grep), WRITE (includes READ + Write/Edit), or COMMAND (includes WRITE + READ + host command lifecycle). COMMAND runs with this CLI user's host authority and is not sandboxed."
) )
.map_err(|error| format!("failed to write prompt: {error}"))?; .map_err(|error| format!("failed to write prompt: {error}"))?;
let Some(permission_input) = read_prompt_line( let Some(permission_input) = read_prompt_line(
input, input,
output, output,
&format!( &format!(
"Permissions [comma-separated READ/WRITE/COMMAND; current {}; q to cancel]: ", "Permission level [READ/WRITE/COMMAND; current {}; q to cancel]: ",
permissions_label(options.permissions) permissions_label(options.permissions)
), ),
)? )?
@@ -320,28 +315,20 @@ fn configure_interactively(
return Ok(None); return Ok(None);
} }
if !permission_input.is_empty() { if !permission_input.is_empty() {
let mut permissions = server_api::ExternalWorkdirPermissions { options.permissions = match permission_input.to_ascii_lowercase().as_str() {
read: false, "r" | "read" | "read-only" | "read_only" => {
write: false, server_api::ExternalWorkdirPermissions::READ
command: false,
};
for category in permission_input
.split([',', ' '])
.filter(|category| !category.is_empty())
{
match category.to_ascii_lowercase().as_str() {
"r" | "read" | "read-only" | "read_only" => permissions.read = true,
"w" | "write" | "read-write" | "read_write" => permissions.write = true,
"c" | "command" => permissions.command = true,
_ => {
return Err(format!(
"unknown permission category `{category}`; use READ, WRITE, or COMMAND"
));
}
} }
} "w" | "write" | "read-write" | "read_write" => {
validate_permissions(permissions)?; server_api::ExternalWorkdirPermissions::WRITE
options.permissions = permissions; }
"c" | "command" => server_api::ExternalWorkdirPermissions::COMMAND,
_ => {
return Err(format!(
"unknown permission level `{permission_input}`; use READ, WRITE, or COMMAND"
));
}
};
} }
writeln!(output, "TTL: {}", ttl_label(options.ttl)) writeln!(output, "TTL: {}", ttl_label(options.ttl))
@@ -1085,12 +1072,12 @@ mod tests {
} }
#[test] #[test]
fn interactive_share_requires_explicit_write_and_command_selection_and_confirmation() { fn interactive_share_selects_one_hierarchical_level_and_requires_confirmation() {
let workspaces = [ let workspaces = [
workspace("workspace-a", "Alpha"), workspace("workspace-a", "Alpha"),
workspace("workspace-b", "Beta"), workspace("workspace-b", "Beta"),
]; ];
let mut input = std::io::Cursor::new(b"2\nREAD,WRITE,COMMAND\n15m\ny\n".to_vec()); let mut input = std::io::Cursor::new(b"2\nCOMMAND\n15m\ny\n".to_vec());
let mut output = Vec::new(); let mut output = Vec::new();
let selected = let selected =
@@ -1110,7 +1097,39 @@ mod tests {
assert_eq!(selected.ttl, Some(Duration::from_secs(900))); assert_eq!(selected.ttl, Some(Duration::from_secs(900)));
let output = String::from_utf8(output).unwrap(); let output = String::from_utf8(output).unwrap();
assert!(output.contains("not sandboxed")); assert!(output.contains("not sandboxed"));
assert!(output.contains("Permissions: READ, WRITE, COMMAND")); assert!(output.contains("Permissions: COMMAND (includes WRITE + READ)"));
}
#[test]
fn share_permission_levels_map_to_exact_existing_capability_bits() {
let bits = |permissions| {
serde_json::to_value(workdir_capabilities(permissions)).unwrap()["bits"]
.as_u64()
.unwrap()
};
assert_eq!(bits(server_api::ExternalWorkdirPermissions::READ), 25);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::WRITE), 31);
assert_eq!(bits(server_api::ExternalWorkdirPermissions::COMMAND), 63);
for invalid in [
server_api::ExternalWorkdirPermissions {
read: false,
write: false,
command: true,
},
server_api::ExternalWorkdirPermissions {
read: false,
write: true,
command: false,
},
server_api::ExternalWorkdirPermissions {
read: true,
write: false,
command: true,
},
] {
assert!(validate_permissions(invalid).is_err());
}
} }
#[test] #[test]
+31 -16
View File
@@ -1,34 +1,49 @@
# External Workdir sharing permissions # External Workdir sharing permissions
`yoi workdir share` exposes a client-hosted directory through an authenticated, `yoi workdir share` exposes a client-hosted directory through an authenticated,
outbound Backend connection. The operator grants three independent categories: outbound Backend connection. The operator selects exactly one permission level:
- **READ**: file reads and read-only discovery operations such as Stat, List, - **READ** (capability bits `25`): file reads and read-only discovery operations
ViewImage, Glob, and Grep. such as Stat, List, ViewImage, Glob, and Grep.
- **WRITE**: Write and Edit. WRITE requires READ because updating an existing - **WRITE** (capability bits `31`): READ plus Write and Edit. Including READ
file uses the read-before-write content-hash contract. Selecting WRITE never preserves the read-before-write content-hash contract. WRITE never enables
enables COMMAND. COMMAND.
- **COMMAND**: command start, status, bounded output, and cancellation. COMMAND - **COMMAND** (capability bits `63`): WRITE plus READ plus command start, status,
does not enable READ or WRITE. It executes on the sharing host with the OS bounded output, and cancellation. It executes on the sharing host with the OS
authority of the user running the CLI; External Workdir command execution is authority of the user running the CLI; External Workdir command execution is
**not a sandbox** and does not confine the process to the shared directory. **not a sandbox** and does not confine the process to the shared directory.
READ is enabled by default and COMMAND is disabled by default. Interactive mode In short, `COMMAND ⇒ WRITE ⇒ READ`. READ is the default. Interactive mode asks
asks for a comma-separated category list and shows the exact grant before it is for one level and shows its included permissions before creating the grant. For
created. For automation, repeat `--permission`: automation, pass `--permission` once:
```sh ```sh
yoi --backend https://example.invalid workdir share ./project \ yoi --backend https://example.invalid workdir share ./project \
--workspace-id workspace-a \ --workspace-id workspace-a \
--permission read \
--permission write \
--permission command \ --permission command \
--non-interactive --non-interactive
``` ```
`--read-only` remains an alias for READ. `--read-write` remains an alias for `--read-only` remains an alias for the READ level. `--read-write` remains an
READ+WRITE and never enables COMMAND. These legacy flags conflict with explicit alias for the WRITE level and never enables COMMAND. These legacy flags conflict
`--permission` arguments so ambiguous automation fails closed. with `--permission`, and multiple `--permission` arguments are rejected so
ambiguous automation fails closed.
The grant API retains the boolean projection for compatibility, but accepts only
these combinations when creating new grants:
| Level | `read` | `write` | `command` |
|---|---:|---:|---:|
| READ | `true` | `false` | `false` |
| WRITE | `true` | `true` | `false` |
| COMMAND | `true` | `true` | `true` |
Older non-hierarchical grants such as command-only or READ+COMMAND are not
silently widened. The Backend refuses their projection, provider registration,
and subsequent restore/reconnect with guidance to revoke and re-share the
directory at one of the supported levels. General Workdir attachment/session
capability intersections may still produce partial capability sets; that
restriction applies only to External grant authority.
Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between Omitting `--ttl` creates no automatic expiry. An explicit TTL must be between
60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment 60 seconds and 24 hours. Ctrl-C, revoke, expiry, provider disconnect, attachment
+4 -4
View File
@@ -2092,15 +2092,15 @@
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
"command": { "command": {
"description": "Execute commands as the sharing CLI user on the provider host. This is\nexplicit authority and does not imply either filesystem category.", "description": "Execute commands as the sharing CLI user on the provider host. COMMAND\nincludes WRITE and READ, remains explicit, and is not sandboxed.",
"type": "boolean" "type": "boolean"
}, },
"read": { "read": {
"description": "Read files and use read-only discovery operations such as Glob and Grep.", "description": "Read files and use read-only discovery operations such as Glob and Grep.\nEvery supported External Workdir permission level enables this field.",
"type": "boolean" "type": "boolean"
}, },
"write": { "write": {
"description": "Create or update files with Write and Edit. Existing-file mutation also\nrequires `read` so callers can satisfy the read-before-write contract.", "description": "Create or update files with Write and Edit. WRITE includes READ so callers\ncan satisfy the read-before-write contract.",
"type": "boolean" "type": "boolean"
} }
}, },
@@ -14020,7 +14020,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c" "x-yoi-source-digest": "sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "paths": {
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:3a298734fd19f3a224e4f1503385fb8a823b612d328224f45f677ebaca28b96c) // Input contract: 0.1.0 (sha256:7b06d005d3436758c96fc4cfd462df76460f89620fd1b58fdf05009d96bb9052)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -182,7 +182,9 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
); );
const kind = stringField(source, "kind"); const kind = stringField(source, "kind");
if (kind === "repository") { if (kind === "repository") {
if (source.grant_id !== undefined || source.grant_permissions !== undefined) { if (
source.grant_id !== undefined || source.grant_permissions !== undefined
) {
throw new Error( throw new Error(
"Repository Workdir source must not contain External grant authority", "Repository Workdir source must not contain External grant authority",
); );
@@ -201,7 +203,7 @@ function parseWorkingDirectorySource(value: unknown): WorkingDirectorySource {
return { return {
kind, kind,
grant_id: stringField(source, "grant_id"), grant_id: stringField(source, "grant_id"),
grant_permissions: parseExternalWorkdirPermissions( grant_permissions: parseExternalWorkdirGrantPermissions(
source.grant_permissions, source.grant_permissions,
"External Workdir grant permissions", "External Workdir grant permissions",
), ),
@@ -226,6 +228,21 @@ export function parseExternalWorkdirPermissions(
}; };
} }
export function parseExternalWorkdirGrantPermissions(
value: unknown,
label = "External Workdir grant permissions",
): ExternalWorkdirPermissions {
const permissions = parseExternalWorkdirPermissions(value, label);
const hierarchical = permissions.read &&
(!permissions.command || permissions.write);
if (!hierarchical) {
throw new Error(
`${label} must be READ=(true,false,false), WRITE=(true,true,false), or COMMAND=(true,true,true)`,
);
}
return permissions;
}
function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget { function parseCleanupTarget(value: unknown): WorkingDirectoryCleanupTarget {
const record = exactRecord( const record = exactRecord(
value, value,
@@ -106,7 +106,7 @@ Deno.test("Worker summary parser preserves attachment-effective External permiss
source: { source: {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true }, grant_permissions: { read: true, write: true, command: true },
}, },
materializer_kind: "client_hosted_external", materializer_kind: "client_hosted_external",
status: "active", status: "active",
@@ -1,6 +1,6 @@
import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api"; import type { ExternalWorkdirPermissions } from "$lib/generated/workdir-api";
/** Render independent Workdir authority categories in a stable, non-conflating order. */ /** Render an effective attachment/session capability intersection without widening it. */
export function formatWorkdirPermissions( export function formatWorkdirPermissions(
permissions: ExternalWorkdirPermissions, permissions: ExternalWorkdirPermissions,
): string { ): string {
@@ -10,3 +10,21 @@ export function formatWorkdirPermissions(
if (permissions.command) categories.push("COMMAND"); if (permissions.command) categories.push("COMMAND");
return categories.length > 0 ? categories.join(" · ") : "NONE"; return categories.length > 0 ? categories.join(" · ") : "NONE";
} }
/** Render one validated External grant level with its inherited authority. */
export function formatExternalGrantPermissionLevel(
permissions: ExternalWorkdirPermissions,
): string {
if (permissions.read && permissions.write && permissions.command) {
return "COMMAND (includes WRITE + READ)";
}
if (permissions.read && permissions.write && !permissions.command) {
return "WRITE (includes READ)";
}
if (permissions.read && !permissions.write && !permissions.command) {
return "READ";
}
throw new Error(
"External Workdir grant has a non-hierarchical permission set",
);
}
@@ -12,7 +12,7 @@
parseWorkerLaunchOptionsResponse, parseWorkerLaunchOptionsResponse,
} from '$lib/workspace/api/workers'; } from '$lib/workspace/api/workers';
import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision'; import { formatCurrentWorkdirRevision } from '$lib/workspace/settings/workdir-revision';
import { formatWorkdirPermissions } from '$lib/workspace/settings/workdir-permissions'; import { formatExternalGrantPermissionLevel } from '$lib/workspace/settings/workdir-permissions';
import { import {
buildCreateWorkspaceWorkerRequest, buildCreateWorkspaceWorkerRequest,
defaultWorkerLaunchForm, defaultWorkerLaunchForm,
@@ -40,7 +40,7 @@
: null; : null;
const label = directory.display_name ?? repositoryKey ?? 'External Workdir'; const label = directory.display_name ?? repositoryKey ?? 'External Workdir';
if (directory.source.kind === 'external_grant') { if (directory.source.kind === 'external_grant') {
return `${label} · External · ${formatWorkdirPermissions(directory.source.grant_permissions)}`; return `${label} · External · ${formatExternalGrantPermissionLevel(directory.source.grant_permissions)}`;
} }
return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`; return `${label} · ${formatCurrentWorkdirRevision(directory, provider)}`;
} }
+12 -3
View File
@@ -56,7 +56,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
source: { source: {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
grant_permissions: { read: true, write: false, command: true }, grant_permissions: { read: true, write: true, command: true },
}, },
materializer_kind: "client_hosted_external", materializer_kind: "client_hosted_external",
status: "active", status: "active",
@@ -72,7 +72,7 @@ Deno.test("Workdir REST validation accepts External grant sources without Runtim
} }
if ( if (
!detail.item.source.grant_permissions.read || !detail.item.source.grant_permissions.read ||
detail.item.source.grant_permissions.write || !detail.item.source.grant_permissions.write ||
!detail.item.source.grant_permissions.command !detail.item.source.grant_permissions.command
) { ) {
throw new Error("External Workdir grant permissions were not preserved"); throw new Error("External Workdir grant permissions were not preserved");
@@ -83,6 +83,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
for ( for (
const source of [ const source of [
{ kind: "external_grant", grant_id: "grant-1" }, { kind: "external_grant", grant_id: "grant-1" },
{
kind: "external_grant",
grant_id: "grant-1",
grant_permissions: { read: false, write: false, command: true },
},
{ {
kind: "external_grant", kind: "external_grant",
grant_id: "grant-1", grant_id: "grant-1",
@@ -113,7 +118,11 @@ Deno.test("Workdir REST validation rejects missing or misplaced External grant p
rejected = true; rejected = true;
} }
if (!rejected) { if (!rejected) {
throw new Error(`invalid External permission projection was accepted: ${JSON.stringify(source)}`); throw new Error(
`invalid External permission projection was accepted: ${
JSON.stringify(source)
}`,
);
} }
} }
}); });
@@ -2,9 +2,12 @@ declare const Deno: {
test(name: string, fn: () => void | Promise<void>): void; test(name: string, fn: () => void | Promise<void>): void;
}; };
import { formatWorkdirPermissions } from "../src/lib/workspace/settings/workdir-permissions.ts"; import {
formatExternalGrantPermissionLevel,
formatWorkdirPermissions,
} from "../src/lib/workspace/settings/workdir-permissions.ts";
Deno.test("Workdir permissions render independent categories in stable order", () => { Deno.test("effective Workdir permissions render partial intersections without widening", () => {
const cases = [ const cases = [
[{ read: true, write: false, command: false }, "READ"], [{ read: true, write: false, command: false }, "READ"],
[{ read: true, write: true, command: false }, "READ · WRITE"], [{ read: true, write: true, command: false }, "READ · WRITE"],
@@ -20,3 +23,32 @@ Deno.test("Workdir permissions render independent categories in stable order", (
} }
} }
}); });
Deno.test("External grant levels show inherited authority and reject partial grants", () => {
const cases = [
[{ read: true, write: false, command: false }, "READ"],
[{ read: true, write: true, command: false }, "WRITE (includes READ)"],
[
{ read: true, write: true, command: true },
"COMMAND (includes WRITE + READ)",
],
] as const;
for (const [permissions, expected] of cases) {
const actual = formatExternalGrantPermissionLevel(permissions);
if (actual !== expected) {
throw new Error(`expected ${expected}, got ${actual}`);
}
}
let rejected = false;
try {
formatExternalGrantPermissionLevel({
read: false,
write: false,
command: true,
});
} catch {
rejected = true;
}
if (!rejected) throw new Error("command-only External grant was accepted");
});