1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
use sysknife_brain::planner::PlanRiskLevel;
use crate::approval::MaxRisk;
/// All CLI error categories with their exit-code mapping.
#[derive(Debug, thiserror::Error)]
pub enum CliError {
#[error("plan rejected by user")]
Rejected,
#[error("execution failed: {0}")]
ExecutionFailed(String),
#[error("operation timed out after {0}s")]
TimedOut(u64),
/// The daemon requested a reboot. The run is not failed, but it is not
/// complete; continuing with later dependent steps would be unsafe.
#[error("execution paused: {0}")]
NeedsReboot(String),
#[error("planning failed: {0}")]
PlanningFailed(String),
/// The planner declined: no valid action can satisfy the request.
///
/// Separate from [`PlanningFailed`](Self::PlanningFailed) because it is not
/// a failure. The planner understood the request and answered; the answer is
/// no. Rendering it as "planning failed" would tell the operator something
/// broke, when in fact the one thing that could go wrong here — inventing an
/// adjacent action to satisfy the schema — is exactly what did NOT happen
/// (#179).
#[error("cannot satisfy that request: {reason}")]
Refused {
reason: String,
suggestion: Option<String>,
},
#[error("config/daemon error: {0}")]
ConfigOrDaemon(String),
#[error("plan contains a {} step, but --max-risk ceiling is {}", .highest.as_str(), .ceiling.as_str())]
RiskCeilingExceeded {
/// The highest risk level present in the plan (from the domain type).
highest: PlanRiskLevel,
/// The CLI-supplied ceiling (from the `--max-risk` flag).
ceiling: MaxRisk,
},
/// Produced when `ApprovalDecision::RequiresInteraction` occurs: the plan
/// needs human approval but `--non-interactive` was set.
///
/// Exit code 1 — same bucket as `Rejected`: both mean "cannot proceed,
/// a human decision is required before this can run".
#[error("plan requires interactive approval but --non-interactive was set")]
NonInteractive,
/// `--dangerously-skip-approval` without `SYSKNIFE_I_ACCEPT_UNATTENDED_ROOT=1`.
///
/// Exit code 1, the "cannot proceed, and that is a legitimate outcome"
/// bucket, rather than clap's usual 2 for a usage error. Nothing
/// malfunctioned and no argument was malformed; the operator asked to run
/// unattended and has not completed the second half of the handshake. A
/// script that already treats 1 as "SysKnife declined to act" gets the
/// right answer without a new case.
#[error("{0}")]
UnattendedConsentMissing(String),
/// Produced when `sysknife approve` is run without a terminal on stdin.
///
/// Distinct from [`Self::NonInteractive`] because the cause is different and
/// the old shared message was simply wrong here: `approve` has no
/// `--non-interactive` flag, so telling the user it "was set" sent anyone
/// who piped or scripted the command looking for a flag that does not exist.
///
/// Exit code 1 — a human decision is still required before this can run.
#[error(
"sysknife approve needs a terminal to confirm on, and stdin is not one \
(it is a pipe, a redirect, or a non-interactive ssh command). \
Run it directly in a shell."
)]
ApprovalNeedsTerminal,
/// The proposed change did not fit the approval view.
///
/// The prompt marked the omission and let the approval through anyway, so a
/// receipt could be taken on a change the operator had seen the first forty
/// lines of. The receipt still proved a human typed a word; it no longer
/// proved the human read what they were approving. Refusing, and naming the
/// flag that shows all of it, is the whole fix: nothing becomes
/// unapprovable, it costs one word on the command line.
#[error(
"the proposed change does not fit the approval view: {withheld} line(s) were not \
shown{shortened_note}. Approving would mean consenting to text you were not shown. \
Re-run with --full to see all of it: sysknife approve {transaction_id} --full"
)]
ApprovalViewIncomplete {
transaction_id: String,
withheld: usize,
shortened_note: String,
},
/// Produced by subcommands that have their own exit-code semantics (e.g.
/// `sysknife audit verify` uses 0/1/2). The wrapped value is the literal
/// exit code the process should return.
#[error("subcommand exit code {0}")]
Exit(i32),
}
impl CliError {
pub fn exit_code(&self) -> i32 {
match self {
// 1 is the "cannot proceed, and that is a legitimate outcome"
// bucket. A refusal belongs here with Rejected, not with the
// PlanningFailed fault bucket: nothing malfunctioned.
Self::Rejected
| Self::Refused { .. }
| Self::RiskCeilingExceeded { .. }
| Self::NonInteractive
| Self::UnattendedConsentMissing(_)
| Self::ApprovalNeedsTerminal
| Self::ApprovalViewIncomplete { .. } => 1,
Self::ExecutionFailed(_) | Self::TimedOut(_) => 2,
Self::PlanningFailed(_) => 3,
Self::ConfigOrDaemon(_) => 4,
Self::NeedsReboot(_) => 5,
Self::Exit(code) => *code,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A refusal shares the "legitimate no" bucket with Rejected. Scripts that
/// branch on exit code should not have to treat "that request is impossible"
/// as an internal fault.
#[test]
fn exit_code_refused_is_1_not_the_planning_fault_code() {
let refused = CliError::Refused {
reason: "Port 0 is not a valid port number.".into(),
suggestion: None,
};
assert_eq!(refused.exit_code(), 1);
assert_ne!(
refused.exit_code(),
CliError::PlanningFailed(String::new()).exit_code(),
"a refusal is not a planning failure"
);
}
#[test]
fn exit_code_rejected_is_1() {
assert_eq!(CliError::Rejected.exit_code(), 1);
}
#[test]
fn exit_code_risk_ceiling_exceeded_is_1() {
assert_eq!(
CliError::RiskCeilingExceeded {
highest: PlanRiskLevel::High,
ceiling: MaxRisk::Medium,
}
.exit_code(),
1
);
}
#[test]
fn exit_code_non_interactive_is_1() {
assert_eq!(CliError::NonInteractive.exit_code(), 1);
}
#[test]
fn exit_code_approval_needs_terminal_is_1() {
assert_eq!(CliError::ApprovalNeedsTerminal.exit_code(), 1);
}
#[test]
fn a_missing_terminal_is_not_reported_as_a_flag_the_user_never_passed() {
// `sysknife approve` has no --non-interactive flag, so the shared
// message used to state a cause that could not be true.
let msg = CliError::ApprovalNeedsTerminal.to_string();
assert!(
!msg.contains("--non-interactive"),
"must not blame a flag that does not exist for this subcommand: {msg}"
);
assert!(msg.contains("terminal"), "names the real cause: {msg}");
assert!(
msg.contains("Run it directly in a shell"),
"tells the user what to do instead: {msg}"
);
// The genuine flag case keeps its own, accurate wording.
assert!(CliError::NonInteractive
.to_string()
.contains("--non-interactive"));
}
#[test]
fn exit_code_execution_failed_is_2() {
assert_eq!(CliError::ExecutionFailed("boom".into()).exit_code(), 2);
}
#[test]
fn exit_code_planning_failed_is_3() {
assert_eq!(CliError::PlanningFailed("bad".into()).exit_code(), 3);
}
#[test]
fn exit_code_config_or_daemon_is_4() {
assert_eq!(CliError::ConfigOrDaemon("nope".into()).exit_code(), 4);
}
}