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
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
//! The write-safety gate.
//!
//! Every mutating dispatch site — TUI, CLI and MCP alike — funnels
//! through `deny_write` / `deny_write_batch` before it touches AWS.
//! `--deny-write`, `safety.envs.NAME.read_only` and
//! `safety.accounts.NAME.read_only` are all resolved here, so there is
//! exactly one place to audit.
use super::*;
impl App {
/// Resolve the effective read-only lock for a destructive action
/// against `env_name`. Layered:
///
/// 1. Global `--read-only` flag / `:readonly on` (master switch).
/// 2. Per-env safety pin (`safety.envs.NAME.read_only = true` in
/// config.toml).
/// 3. Per-account safety pin (`safety.accounts.NAME.read_only = true`)
/// matched against the active profile name.
///
/// Any of these returning `true` blocks the action; the operator-
/// facing error message can differentiate via `read_only_reason`.
pub(crate) fn is_read_only_for(&self, env_name: &str) -> bool {
// Deliberately delegating rather than repeating the chain.
// These were two separate four-branch cascades kept in the same
// order by a comment asking a human to do it — and the ONE
// difference between them would have been invisible: a
// predicate that says "allowed" while the reason function has
// something to say is a write that slips through, and the
// reverse is a refusal with no explanation. The allocation is
// irrelevant here (this runs on a confirm, or once per env in a
// batch of tens — never per frame).
self.read_only_reason(env_name).is_some()
}
/// Enforce the read-only gate for a destructive action against
/// `env_name`. Returns `true` (and sets `self.error_message` to a
/// `"<reason> — <verb> disabled"` toast) when the env is locked;
/// `false` (no side effects) otherwise. Designed to be the single
/// guard at the top of every `spawn_*`-style destructive helper:
///
/// ```ignore
/// if self.deny_write(&env.name, "rollback") { return; }
/// ```
///
/// Saves duplicating the `is_read_only_for` + `read_only_reason`
/// + `error_message` triplet at every call site (~25 of them).
pub(crate) fn deny_write(&mut self, env_name: &str, verb: &str) -> bool {
// `--demo` mode refuses writes outright (see spawn_action's
// matching guard for the rationale — synthetic fleet, fake
// AwsClient, real audit log).
//
// When BOTH demo_mode and a safety-pin / read-only lock apply,
// mention both in the toast — operators using `--demo` to
// validate their `safety.envs.*` / `safety.accounts.*` config
// before going live shouldn't have to exit demo to confirm
// the pin is wired correctly. (0.17.4 review)
if self.demo_mode {
let pin_reason = self.read_only_reason(env_name);
let suffix = match pin_reason {
Some(reason) => format!(" — would also refuse: {reason}"),
None => String::new(),
};
self.error_message = Some(format!(
"demo mode — {verb} not dispatched (writes are inert; press q to exit){suffix}"
));
return true;
}
let Some(refusal) = self.refusal_for(env_name) else {
return false;
};
// Record the attempt. Until this landed a blocked write left no
// trace whatsoever — the dispatch never happened, so there was
// no dispatched/completed pair, and repeated attempts on a
// pinned env were indistinguishable from nobody trying.
self.audit_refusal(env_name, verb, &refusal);
let reason = self.render_refusal(&refusal);
self.error_message = Some(format!("{reason} — {verb} disabled"));
true
}
/// Read-only gate for a *batch* destructive op over `env_names`.
/// Returns `true` (and sets `self.error_message`) when the op must
/// be refused. Unlike single-env [`App::deny_write`], a batch is gated
/// per-env: if ANY selected env is locked the whole batch is
/// refused (refuse-all, not skip-some — a safety pin shouldn't be
/// silently routed around for the unpinned remainder), with the
/// locked env names named so the operator can deselect them.
///
/// Catches the env-independent gates (`--demo`, global read-only,
/// `:freeze-deploys`) first via a representative `is_read_only_for`
/// probe so those produce their normal whole-fleet message, then
/// scans for per-env / per-account pins. Mirrors the precedence in
/// [`App::is_read_only_for`]. `verb` names the op for the toast.
pub(crate) fn deny_write_batch(&mut self, env_names: &[String], verb: &str) -> bool {
// Env-independent gates produce the familiar whole-fleet toast
// ("demo mode …" / "read-only mode …" / "deploys frozen …")
// rather than a per-env list. Which rungs those ARE is asked of
// `write_gate`, not restated here: this condition used to name
// them by hand and had already drifted by one — a config the
// parser could not read fell through to the per-env scan and
// reported "N of N selected env(s) locked", which describes
// pins the operator does not have.
let probe = env_names.first().map(|s| s.as_str()).unwrap_or("");
if self.demo_mode || self.refusal_for(probe).is_some_and(|r| !r.is_env_scoped()) {
return self.deny_write(probe, verb);
}
let locked: Vec<String> = env_names
.iter()
.filter(|n| self.is_read_only_for(n))
.cloned()
.collect();
if locked.is_empty() {
return false;
}
// Use the first locked env's reason as the headline (per-env
// and per-account pins read the same regardless of which env);
// list the locked names so the operator knows what to deselect.
let reason = self
.read_only_reason(&locked[0])
.unwrap_or_else(|| "read-only mode".into());
// One line PER locked env, not one line naming them all joined.
//
// The joined form filed `target=env-a,env-b`, which matches no
// env — so `ebman audit --env env-a` found nothing, and
// `audit_refusal`'s region lookup missed too and fell back to
// the home region. That is the wrong-region bug `region_for_name`
// carries a comment about, reintroduced by a target string that
// was never an env name.
for env in &locked {
if let Some(refusal) = self.refusal_for(env) {
self.audit_refusal(env, verb, &refusal);
}
}
self.error_message = Some(format!(
"{reason} — {verb} refused: {} of {} selected env(s) locked ({})",
locked.len(),
env_names.len(),
locked.join(", ")
));
true
}
/// Human-readable explanation of *why* an env is read-only, used
/// in the toast / footer when a destructive action is blocked.
/// Returns `None` when the env isn't locked (caller shouldn't have
/// called this; defensive return). The three reasons are ordered
/// to match `is_read_only_for`'s precedence.
pub(crate) fn read_only_reason(&self, env_name: &str) -> Option<String> {
Some(self.render_refusal(&self.refusal_for(env_name)?))
}
/// The typed decision for `env_name`, before any wording is applied.
///
/// Split out from `read_only_reason` because a refusal now has to be
/// *recorded* as well as shown, and the audit log needs the rule
/// name — which is precisely what rendering throws away.
pub(crate) fn refusal_for(&self, env_name: &str) -> Option<crate::write_gate::Refusal> {
// The DECISION is `write_gate::decide`, shared with the CLI and
// MCP paths. The WORDING below is not shared and should not be:
// a toast can afford the freeze age and the `:incident END`
// hint, and a CLI line cannot. Converging the messages too
// would have been a visible regression for no benefit.
crate::write_gate::decide(&crate::write_gate::WriteContext {
env: env_name,
profile: self.context.profile.as_deref(),
safety_parse_errors: &self.cfg.safety_parse_errors,
global_read_only: self.read_only,
frozen: self.deploy_freeze.is_some(),
safety_envs: &self.cfg.safety_envs,
safety_accounts: &self.cfg.safety_accounts,
})
}
/// Render a refusal in the TUI's voice — the freeze age, the
/// `:incident END` hint. See `write_gate`'s module docs for why this
/// is deliberately not shared with the CLI.
fn render_refusal(&self, refusal: &crate::write_gate::Refusal) -> String {
match refusal {
crate::write_gate::Refusal::SafetyConfigUnreadable { problem } => {
// The problem string names the offending line, which is
// the whole point: "your safety config is broken" sends
// the operator hunting.
format!("safety config unreadable — {problem}")
}
crate::write_gate::Refusal::GlobalReadOnly => "read-only mode (global toggle)".into(),
crate::write_gate::Refusal::Frozen => {
// `decide` only reports THAT a freeze applies; the
// detail lives here because only this surface has room
// for it.
//
// NOT `as_ref()?`. That was the first version, and `?`
// in a function returning `Option<String>` propagates
// `None` — which here means "not read-only", i.e. the
// write proceeds. Unreachable today (both values come
// from the same `Option` in the same function), but a
// fail-OPEN shape inside a write gate is the one thing
// this module exists to avoid. Refuse with less detail
// instead.
let Some(freeze) = self.deploy_freeze.as_ref() else {
return "deploys frozen".into();
};
let age = (chrono::Utc::now() - freeze.frozen_at).num_seconds().max(0);
let age =
crate::app::humanize_short_age(std::time::Duration::from_secs(age as u64));
// When the freeze came from `:incident START`, point the
// operator at the gesture that actually closes it — a
// bare :thaw-deploys would lift the lock but leave the
// incident banner up, which is rarely what they meant.
let unlock_hint = if self.incident.is_some() {
":incident END to close"
} else {
":thaw-deploys to unfreeze"
};
if freeze.reason.is_empty() {
format!("deploys frozen ({age} ago) — {unlock_hint}")
} else {
format!(
"deploys frozen ({age} ago): {} — {unlock_hint}",
freeze.reason
)
}
}
crate::write_gate::Refusal::EnvPinned { env } => {
format!("read-only mode (env pinned via safety.envs.{env})")
}
crate::write_gate::Refusal::AccountPinned { profile } => {
format!("read-only mode (account pinned via safety.accounts.{profile})")
}
}
}
/// Put the safety-config banner back into an empty error slot.
///
/// One function rather than the expression repeated at each site
/// that clears `error_message`: the first version was inline at the
/// refresh handler and immediately missed the context-switch path,
/// so the banner blinked out on `:context` until the next refresh
/// completed. A convention every handler has to remember is the
/// shape that already failed once here.
///
/// Only fills an EMPTY slot — a refresh error or a partial-failure
/// notice outranks it, and both are less replaceable: a write
/// refusal re-announces itself in full the moment anything is
/// attempted.
pub(crate) fn reassert_safety_banner(&mut self) {
if self.error_message.is_none() {
self.error_message = crate::app::safety_config_warning(&self.cfg.safety_parse_errors);
}
}
/// Record a refusal in the audit log.
///
/// The region is the ROW's, not home: under a multi-region fan-out
/// the selected env is usually elsewhere, and an audit line filed
/// against the wrong region is worse than none.
fn audit_refusal(&self, target: &str, verb: &str, refusal: &crate::write_gate::Refusal) {
crate::audit::append_action_refused(
self.context.account_id.as_deref(),
self.context.profile.as_deref(),
&self.region_for_name(target),
verb,
target,
refusal.rule(),
&refusal.remedy(),
);
}
}