Update public CLI confirmation gates

This commit is contained in:
Michel Paulissen 2026-07-04 11:09:58 +02:00
parent 66fec04e75
commit 7a80d87376
4 changed files with 250 additions and 5 deletions

View file

@ -937,8 +937,51 @@ fn non_interactive_browser_login_report(args: &LoginArgs) -> Value {
})
}
fn confirmation_required_report(
command: &str,
operation: &str,
target: Value,
confirm_command: String,
) -> Value {
let message = format!("{command} requires --yes before {operation}");
let next_actions = json!([
confirm_command,
"review the command target before confirming",
"rerun with --json to inspect the safe failure"
]);
let mut machine_error = cli_error_summary_for_category("policy", &message);
if let Some(object) = machine_error.as_object_mut() {
object.insert("confirmation_required".to_owned(), json!(true));
object.insert("next_actions".to_owned(), next_actions.clone());
}
json!({
"command": command,
"status": "confirmation_required",
"operation": operation,
"target": target,
"requires_confirmation": true,
"confirmation_required": true,
"explicit_user_action_required": true,
"coordinator_request_sent": false,
"safe_failure": true,
"next_actions": next_actions,
"machine_error": machine_error,
})
}
fn logout_report(args: AuthLogoutArgs, cwd: PathBuf, command: &str) -> Result<Value> {
let session_file = session_config_file(&cwd);
if !args.yes {
return Ok(confirmation_required_report(
command,
"delete_cli_session",
json!({
"session_file": session_file,
"node_credentials_untouched": true,
}),
format!("disasmer {command} --yes"),
));
}
let existed = session_file.exists();
if existed {
std::fs::remove_file(&session_file)
@ -1066,6 +1109,20 @@ fn key_list_report(args: KeyListArgs) -> Result<Value> {
}
fn key_revoke_report(args: KeyRevokeArgs) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"key revoke",
"revoke_agent_public_key",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"user": args.scope.user,
"agent": args.agent,
}),
format!("disasmer key revoke --agent {} --yes", args.agent),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let tenant = args.scope.tenant.clone();
let project = args.scope.project.clone();
@ -1465,6 +1522,20 @@ fn node_descriptors_report(
}
fn node_revoke_report(args: NodeRevokeArgs) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"node revoke",
"revoke_node_credential",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"user": args.scope.user,
"node": args.node,
}),
format!("disasmer node revoke --node {} --yes", args.node),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let tenant = args.scope.tenant.clone();
let project = args.scope.project.clone();
@ -1533,6 +1604,19 @@ fn process_status_report(args: ProcessStatusArgs) -> Result<Value> {
}
fn process_restart_report(args: ProcessRestartArgs) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"process restart",
"restart_virtual_process",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
}),
format!("disasmer process restart --process {} --yes", args.process),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({
@ -1567,6 +1651,21 @@ fn process_restart_report(args: ProcessRestartArgs) -> Result<Value> {
}
fn process_cancel_report(args: ProcessCancelArgs) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"process cancel",
"cancel_virtual_process",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
"node": args.node,
"task": args.task,
}),
format!("disasmer process cancel --process {} --yes", args.process),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request(json!({
@ -1621,6 +1720,23 @@ fn task_list_report(args: TaskListArgs) -> Result<Value> {
}
fn task_restart_report(args: TaskRestartArgs) -> Result<Value> {
if !args.yes {
return Ok(confirmation_required_report(
"task restart",
"restart_selected_task",
json!({
"coordinator": args.scope.coordinator,
"tenant": args.scope.tenant,
"project": args.scope.project,
"process": args.process,
"task": args.task,
}),
format!(
"disasmer task restart {} --process {} --yes",
args.task, args.process
),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let mut session = JsonLineSession::connect(coordinator)?;
let response = session.request_allow_error(json!({
@ -2082,6 +2198,19 @@ fn admin_suspend_tenant_report(args: AdminSuspendTenantArgs) -> Result<Value> {
let tenant = args
.target_tenant
.unwrap_or_else(|| args.scope.tenant.clone());
if !args.yes {
return Ok(confirmation_required_report(
"admin suspend-tenant",
"suspend_tenant",
json!({
"coordinator": args.scope.coordinator,
"actor_tenant": args.scope.tenant,
"actor_user": args.scope.user,
"target_tenant": tenant,
}),
"disasmer admin suspend-tenant --yes".to_owned(),
));
}
if let Some(coordinator) = &args.scope.coordinator {
let actor_tenant = args.scope.tenant.clone();
let actor_user = args.scope.user.clone();
@ -6779,6 +6908,27 @@ mod tests {
fs::create_dir_all(session_file.parent().unwrap()).unwrap();
fs::write(&session_file, br#"{"kind":"human","token":"local"}"#).unwrap();
let unconfirmed = logout_report(
AuthLogoutArgs {
yes: false,
scope: CliScopeArgs {
coordinator: None,
tenant: "tenant".to_owned(),
project: "project".to_owned(),
user: "user".to_owned(),
json: false,
},
},
temp.path().to_path_buf(),
"logout",
)
.unwrap();
assert_eq!(unconfirmed["status"], "confirmation_required");
assert_eq!(unconfirmed["coordinator_request_sent"], false);
assert_eq!(unconfirmed["machine_error"]["category"], "policy");
assert!(session_file.exists());
let report = logout_report(
AuthLogoutArgs {
yes: true,
@ -6802,6 +6952,72 @@ mod tests {
assert!(!session_file.exists());
}
#[test]
fn mutating_commands_require_yes_before_side_effects() {
let scope = CliScopeArgs {
coordinator: Some("127.0.0.1:9".to_owned()),
tenant: "tenant".to_owned(),
project: "project".to_owned(),
user: "user".to_owned(),
json: false,
};
let reports = [
key_revoke_report(KeyRevokeArgs {
scope: scope.clone(),
agent: "agent-ci".to_owned(),
yes: false,
})
.unwrap(),
node_revoke_report(NodeRevokeArgs {
scope: scope.clone(),
node: "node-a".to_owned(),
yes: false,
})
.unwrap(),
process_restart_report(ProcessRestartArgs {
scope: scope.clone(),
process: "vp".to_owned(),
yes: false,
})
.unwrap(),
process_cancel_report(ProcessCancelArgs {
scope: scope.clone(),
process: "vp".to_owned(),
node: None,
task: None,
yes: false,
})
.unwrap(),
task_restart_report(TaskRestartArgs {
scope: scope.clone(),
task: "compile-linux".to_owned(),
process: "vp".to_owned(),
yes: false,
})
.unwrap(),
admin_suspend_tenant_report(AdminSuspendTenantArgs {
scope,
target_tenant: Some("tenant".to_owned()),
yes: false,
})
.unwrap(),
];
for report in reports {
assert_eq!(report["status"], "confirmation_required");
assert_eq!(report["requires_confirmation"], true);
assert_eq!(report["coordinator_request_sent"], false);
assert_eq!(report["safe_failure"], true);
assert_eq!(report["machine_error"]["category"], "policy");
assert_eq!(report["machine_error"]["confirmation_required"], true);
assert!(report["next_actions"]
.as_array()
.unwrap()
.iter()
.any(|action| action.as_str().unwrap_or_default().contains("--yes")));
}
}
#[test]
fn cli_first_json_mode_parses_for_primary_commands() {
for args in [
@ -7771,7 +7987,7 @@ mod tests {
process: "vp".to_owned(),
node: None,
task: None,
yes: false,
yes: true,
})
.unwrap();
server.join().unwrap();
@ -7803,7 +8019,7 @@ mod tests {
"compile-linux"
);
assert_eq!(cancel["cancel_request"]["affected_nodes"][1], "node-b");
assert_eq!(cancel["cancel_request"]["requires_confirmation"], true);
assert_eq!(cancel["cancel_request"]["requires_confirmation"], false);
assert_eq!(cancel["cancel_request"]["website_required"], false);
assert_eq!(
cancel["cancel_request"]["whole_process_cancel_available"],
@ -8100,7 +8316,8 @@ mod tests {
yes: false,
})
.unwrap();
assert_eq!(cancel["status"], "requires_coordinator");
assert_eq!(cancel["status"], "confirmation_required");
assert_eq!(cancel["requires_confirmation"], true);
assert_eq!(cancel["coordinator_request_sent"], false);
}
}