cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit 74b1e7d6e82b6db20eceb3ea659400d165a63e5f
parent 4f740383dd0d0b20a0f8ced330bc2d099f3e3b12
Author: triesap <tyson@radroots.org>
Date:   Thu,  7 May 2026 02:28:00 +0000

cli: scope signer gate by operation

- add registry-owned network and signer requirements
- let read inspection commands run under myc mode
- keep signed write surfaces fail-closed for deferred myc
- cover requirement metadata and myc inspection behavior

Diffstat:
Msrc/main.rs | 62++++++++++++++++----------------------------------------------
Msrc/operation_registry.rs | 118++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-
Mtests/signer_runtime_modes.rs | 22++++++++++++++++++++++
3 files changed, 155 insertions(+), 47 deletions(-)

diff --git a/src/main.rs b/src/main.rs @@ -35,6 +35,9 @@ use crate::operation_farm::FarmOperationService; use crate::operation_listing::ListingOperationService; use crate::operation_market::MarketOperationService; use crate::operation_order::OrderOperationService; +use crate::operation_registry::{ + NetworkRequirement, network_requirement, requires_local_signer_mode, +}; use crate::operation_runtime::RuntimeOperationService; use crate::output_contract::OutputEnvelope; use crate::runtime::config::{RuntimeConfig, SignerBackend}; @@ -385,8 +388,10 @@ fn validate_signer_mode_contract( request: &TargetOperationRequest, config: &RuntimeConfig, ) -> Result<(), OperationAdapterError> { - if matches!(config.signer.backend, SignerBackend::Myc) { - let spec = request.spec(); + let spec = request.spec(); + if matches!(config.signer.backend, SignerBackend::Myc) + && requires_local_signer_mode(spec.operation_id) + { return Err(OperationAdapterError::SignerModeDeferred { operation_id: spec.operation_id.to_owned(), message: format!( @@ -403,12 +408,14 @@ fn validate_network_contract( config: &RuntimeConfig, ) -> Result<(), OperationAdapterError> { let spec = request.spec(); - let external = external_network_operation(spec.operation_id); + let requirement = network_requirement(spec.operation_id); match request.context().network_mode { OperationNetworkMode::Default => Ok(()), OperationNetworkMode::Offline => { - if external - && (!request.context().dry_run || dry_run_requires_network(spec.operation_id)) + if let NetworkRequirement::External { + dry_run_requires_network, + } = requirement + && (!request.context().dry_run || dry_run_requires_network) { return Err(OperationAdapterError::OfflineForbidden { operation_id: spec.operation_id.to_owned(), @@ -421,8 +428,10 @@ fn validate_network_contract( Ok(()) } OperationNetworkMode::Online => { - if external - && (!request.context().dry_run || dry_run_requires_network(request.operation_id())) + if let NetworkRequirement::External { + dry_run_requires_network, + } = requirement + && (!request.context().dry_run || dry_run_requires_network) && config.relay.urls.is_empty() { return Err(OperationAdapterError::NetworkUnavailable { @@ -438,45 +447,6 @@ fn validate_network_contract( } } -fn dry_run_requires_network(operation_id: &str) -> bool { - matches!( - operation_id, - "order.accept" - | "order.decline" - | "order.cancel" - | "order.revision.propose" - | "order.revision.accept" - | "order.revision.decline" - | "order.fulfillment.update" - | "order.receipt.record" - ) -} - -fn external_network_operation(operation_id: &str) -> bool { - matches!( - operation_id, - "sync.pull" - | "sync.push" - | "sync.watch" - | "market.refresh" - | "farm.publish" - | "listing.publish" - | "listing.archive" - | "order.submit" - | "order.accept" - | "order.decline" - | "order.cancel" - | "order.revision.propose" - | "order.revision.accept" - | "order.revision.decline" - | "order.fulfillment.update" - | "order.receipt.record" - | "order.status.get" - | "order.event.list" - | "order.event.watch" - ) -} - fn failure_envelope( request: &TargetOperationRequest, error: OperationAdapterError, diff --git a/src/operation_registry.rs b/src/operation_registry.rs @@ -39,6 +39,12 @@ pub enum OperationRole { Seller, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkRequirement { + Local, + External { dry_run_requires_network: bool }, +} + macro_rules! operation { ( $operation_id:literal, @@ -1104,6 +1110,46 @@ pub fn get_operation(operation_id: &str) -> Option<&'static OperationSpec> { .find(|operation| operation.operation_id == operation_id) } +pub fn network_requirement(operation_id: &str) -> NetworkRequirement { + match operation_id { + "sync.pull" | "sync.push" | "sync.watch" | "market.refresh" | "farm.publish" + | "listing.publish" | "listing.archive" | "order.submit" | "order.status.get" + | "order.event.list" | "order.event.watch" => NetworkRequirement::External { + dry_run_requires_network: false, + }, + "order.accept" + | "order.decline" + | "order.cancel" + | "order.revision.propose" + | "order.revision.accept" + | "order.revision.decline" + | "order.fulfillment.update" + | "order.receipt.record" => NetworkRequirement::External { + dry_run_requires_network: true, + }, + _ => NetworkRequirement::Local, + } +} + +pub fn requires_local_signer_mode(operation_id: &str) -> bool { + matches!( + operation_id, + "signer.status.get" + | "farm.publish" + | "listing.publish" + | "listing.archive" + | "order.submit" + | "order.accept" + | "order.decline" + | "order.cancel" + | "order.revision.propose" + | "order.revision.accept" + | "order.revision.decline" + | "order.fulfillment.update" + | "order.receipt.record" + ) +} + pub fn registry_linkage_is_valid() -> bool { OPERATION_REGISTRY.iter().all(|operation| { get_operation(operation.operation_id).is_some() @@ -1117,7 +1163,10 @@ pub fn registry_linkage_is_valid() -> bool { mod tests { use std::collections::BTreeSet; - use super::{ApprovalPolicy, OPERATION_REGISTRY, OperationRole, RiskLevel, get_operation}; + use super::{ + ApprovalPolicy, NetworkRequirement, OPERATION_REGISTRY, OperationRole, RiskLevel, + get_operation, network_requirement, requires_local_signer_mode, + }; const EXPECTED_OPERATION_IDS: &[&str] = &[ "workspace.init", @@ -1369,6 +1418,73 @@ mod tests { } #[test] + fn registry_network_requirements_are_explicit() { + let external = OPERATION_REGISTRY + .iter() + .filter(|operation| { + matches!( + network_requirement(operation.operation_id), + NetworkRequirement::External { .. } + ) + }) + .map(|operation| operation.operation_id) + .collect::<BTreeSet<_>>(); + let expected = [ + "sync.pull", + "sync.push", + "sync.watch", + "market.refresh", + "farm.publish", + "listing.publish", + "listing.archive", + "order.submit", + "order.accept", + "order.decline", + "order.cancel", + "order.revision.propose", + "order.revision.accept", + "order.revision.decline", + "order.fulfillment.update", + "order.receipt.record", + "order.status.get", + "order.event.list", + "order.event.watch", + ] + .into_iter() + .collect::<BTreeSet<_>>(); + + assert_eq!(external, expected); + } + + #[test] + fn registry_local_signer_requirements_are_explicit() { + let signed = OPERATION_REGISTRY + .iter() + .filter(|operation| requires_local_signer_mode(operation.operation_id)) + .map(|operation| operation.operation_id) + .collect::<BTreeSet<_>>(); + let expected = [ + "signer.status.get", + "farm.publish", + "listing.publish", + "listing.archive", + "order.submit", + "order.accept", + "order.decline", + "order.cancel", + "order.revision.propose", + "order.revision.accept", + "order.revision.decline", + "order.fulfillment.update", + "order.receipt.record", + ] + .into_iter() + .collect::<BTreeSet<_>>(); + + assert_eq!(signed, expected); + } + + #[test] fn deferred_namespaces_are_absent() { let namespaces = OPERATION_REGISTRY .iter() diff --git a/tests/signer_runtime_modes.rs b/tests/signer_runtime_modes.rs @@ -680,6 +680,28 @@ fn myc_signer_status_does_not_invoke_configured_executable() { } #[test] +fn myc_mode_allows_read_inspection_commands() { + let sandbox = RadrootsCliSandbox::new(); + let missing_myc = sandbox.root().join("bin/missing-myc"); + configure_myc_mode(&sandbox, &missing_myc); + + for args in [ + &["--format", "json", "workspace", "get"][..], + &["--format", "json", "config", "get"][..], + &["--format", "json", "account", "list"][..], + &["--format", "json", "relay", "list"][..], + ] { + let (output, value) = sandbox.json_output(args); + + assert!( + output.status.success(), + "`{args:?}` should remain observable under MYC mode: {value:?}" + ); + assert_eq!(value["errors"].as_array().expect("errors").len(), 0); + } +} + +#[test] fn local_listing_publish_fails_without_local_account_authority() { let sandbox = RadrootsCliSandbox::new(); let listing_file = create_listing_draft(&sandbox, "local-no-account");