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:
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");