ebman 0.45.0

k9s-style TUI for AWS Elastic Beanstalk
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
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
// MCP tool annotations — and, deliberately, the start of the action
// vocabulary the protection-levels design needs.
//
// Two jobs in one table. For MCP clients these are the standard
// `ToolAnnotations` hints, so any client can tell a read from a
// destructive write without parsing prose and can ask for consent
// accordingly. For `docs/design/protection-levels.md` they are the
// per-action attributes a levels engine evaluates over — "reversible
// writes on low-stakes resources" is only expressible if actions carry
// attributes. Doing it once serves both.
//
// The spec is explicit that these are HINTS and must not be relied on
// for security. That matches the design note's first principle: IAM is
// the boundary, this is a guardrail against mistakes.

use serde_json::{json, Value};

/// How one tool behaves, in the terms MCP defines.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct ToolAttrs {
    /// Does not modify its environment.
    pub read_only: bool,
    /// May perform destructive updates. Meaningful only when
    /// `read_only` is false; the spec says to treat it as false for
    /// read-only tools.
    pub destructive: bool,
    /// Calling twice with the same arguments has the same effect as
    /// calling once.
    pub idempotent: bool,
    /// Interacts with entities the server does not control.
    pub open_world: bool,
}

impl ToolAttrs {
    /// A read against AWS: open world, because the account's contents
    /// change without ebman's involvement.
    const fn read() -> Self {
        Self {
            read_only: true,
            destructive: false,
            idempotent: true,
            open_world: true,
        }
    }

    /// A read against something the server owns — the local audit log.
    /// Closed domain, which is the distinction `openWorldHint` exists
    /// to draw. Marking it open would be a small lie in the direction
    /// of "this might reach anywhere", and the hint is worth less every
    /// time it is wrong.
    const fn local_read() -> Self {
        Self {
            open_world: false,
            ..Self::read()
        }
    }
    const fn write(destructive: bool, idempotent: bool) -> Self {
        Self {
            read_only: false,
            destructive,
            idempotent,
            open_world: true,
        }
    }
}

/// Every tool, classified. The table is the point: a tool absent from
/// it fails `every_tool_is_classified`, so a new one cannot ship
/// without someone deciding what it does.
use crate::verb::Verb;

/// Destructiveness for the write verbs comes from the shared vocabulary
/// (`Verb::destructive`), not from a second classification kept here —
/// the backlog item that asked for the annotations table to stop being
/// the vocabulary's home. `confirm_action` and `dlq_undo` are transport
/// and restore mechanics, not verbs, and stay classified by hand.
pub(super) const TOOL_ATTRS: &[(&str, ToolAttrs)] = &[
    // ── reads ──
    ("list_environments", ToolAttrs::read()),
    ("recent_events", ToolAttrs::read()),
    ("get_option_settings", ToolAttrs::read()),
    ("list_versions", ToolAttrs::read()),
    // Read-only despite a peek nudging `receive_count`: annotating a
    // diagnostic read as a mutation would make clients prompt for it,
    // and "flagging everything teaches clients to ignore the flag"
    // applies here as much as it does to `restart`. The counter effect
    // is stated loudly in the description instead, where it is
    // actionable rather than merely cautionary.
    ("worker_queues", ToolAttrs::read()),
    ("recent_logs", ToolAttrs::read()),
    ("why", ToolAttrs::read()),
    ("lint", ToolAttrs::read()),
    ("drift", ToolAttrs::read()),
    ("fleet_cost", ToolAttrs::read()),
    // Local file, no AWS call — verified by reading `tool_audit_log`,
    // which is `std::fs::read_to_string` and nothing else.
    ("audit_log", ToolAttrs::local_read()),
    // Reports this connection's own capabilities and restrictions.
    // Touches neither AWS nor the filesystem — it reads fields already
    // on the server — so it is the one tool guaranteed to answer even
    // when everything it describes is broken. That is the point of it.
    ("doctor", ToolAttrs::local_read()),
    // A write — it sends a message — but a restorative one. Not
    // destructive: it puts something back. Not idempotent: calling it
    // twice would enqueue two copies.
    ("dlq_undo", ToolAttrs::write(false, false)),
    // ── writes ──
    //
    // `restart` bounces the app servers: downtime, but nothing is
    // destroyed and the end state after two restarts is the state after
    // one.
    (
        "restart",
        ToolAttrs::write(Verb::RestartAppServer.destructive(), true),
    ),
    // `rebuild` terminates and recreates every instance. The
    // environment survives, but the instances do not — the confirm
    // modal says so in as many words, and an operator who expected
    // `restart` would be unpleasantly surprised.
    (
        "rebuild",
        ToolAttrs::write(Verb::Rebuild.destructive(), true),
    ),
    // Deploying a version replaces what is running; the previous
    // version is still deployable, so this is reversible rather than
    // destructive. Deploying the same label twice lands in the same
    // place.
    ("deploy", ToolAttrs::write(Verb::Deploy.destructive(), true)),
    // Same reasoning: a setting can be set back.
    (
        "set_option",
        ToolAttrs::write(Verb::SetOption.destructive(), true),
    ),
    // The environment is gone and is not coming back.
    (
        "terminate",
        ToolAttrs::write(Verb::Terminate.destructive(), true),
    ),
    // Destructive, and `dlq_purge` deliberately reads as no less so
    // than `terminate`. An environment can be rebuilt from its
    // configuration; a purged message is gone and there is nothing to
    // rebuild it from. Non-idempotent, all three: the second call acts
    // on a different queue state than the first.
    (
        "dlq_resend",
        ToolAttrs::write(Verb::DlqResend.destructive(), false),
    ),
    (
        "dlq_delete",
        ToolAttrs::write(Verb::DlqDelete.destructive(), false),
    ),
    (
        "dlq_purge",
        ToolAttrs::write(Verb::DlqPurge.destructive(), false),
    ),
    //
    // `confirm_action` is the second half of the two-phase write flow,
    // and it dispatches whatever is pending — which may be a
    // terminate. So it is annotated at its WORST case, not its average:
    // a client that reads `destructive: false` here and skips the
    // consent prompt would skip it for the one call that most needs it.
    //
    // Not idempotent, and this is the one place that differs from the
    // others: the token is single-use, so confirming twice is not the
    // same as confirming once.
    ("confirm_action", ToolAttrs::write(true, false)),
];

/// Look up a tool's attributes.
pub(super) fn attrs_for(name: &str) -> Option<ToolAttrs> {
    TOOL_ATTRS.iter().find(|(n, _)| *n == name).map(|(_, a)| *a)
}

/// The `annotations` object for a tool descriptor, or `None` if the
/// tool is unclassified — in which case emitting nothing is right. A
/// wrong hint is worse than an absent one: a client that trusts
/// `readOnlyHint: true` on a write will not ask before dispatching it.
pub(super) fn annotations_for(name: &str) -> Option<Value> {
    let a = attrs_for(name)?;
    Some(json!({
        "readOnlyHint": a.read_only,
        "destructiveHint": a.destructive,
        "idempotentHint": a.idempotent,
        "openWorldHint": a.open_world,
    }))
}

/// Attach `annotations` to every descriptor in a tool array that has a
/// classification.
pub(super) fn annotate(tools: &mut Value) {
    let Some(arr) = tools.as_array_mut() else {
        return;
    };
    for tool in arr {
        let Some(name) = tool.get("name").and_then(Value::as_str).map(str::to_string) else {
            continue;
        };
        if let Some(ann) = annotations_for(&name) {
            if let Some(obj) = tool.as_object_mut() {
                obj.insert("annotations".into(), ann);
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    /// Every MCP write tool is annotated with its verb's own
    /// destructiveness — the vocabulary is the one classification.
    ///
    /// This table used to be the only home for it, keyed by transport
    /// tool names, so a second consumer (levels, the TUI, assume-role)
    /// would have grown a second table and drifted. Now `Verb::destructive`
    /// decides and the table reads it; this pins that no entry answers
    /// for itself.
    #[test]
    fn every_write_tool_takes_its_destructiveness_from_the_vocabulary() {
        for verb in crate::cli::mcp::writes::WriteVerb::ALL {
            let attrs = attrs_for(verb.tool_name())
                .unwrap_or_else(|| panic!("{} is not classified", verb.tool_name()));
            assert_eq!(
                attrs.destructive,
                verb.verb().destructive(),
                "{}: the annotation disagrees with Verb::destructive",
                verb.tool_name()
            );
        }
    }

    /// Every advertised tool carries annotations.
    ///
    /// This is the guard that makes the table worth having: a new tool
    /// cannot ship without someone deciding whether it reads or writes
    /// and whether it destroys anything. Checked against the REAL
    /// `tools/list` output — a table that agrees only with itself would
    /// prove nothing.
    #[test]
    fn every_advertised_tool_is_classified() {
        for allow_writes in [false, true] {
            let table = super::super::tools::tool_table(
                &if allow_writes {
                    super::super::WriteScope::All
                } else {
                    super::super::WriteScope::None
                },
                true,
            );
            let arr = table.as_array().expect("tools/list is an array");
            assert!(
                arr.len() >= 8,
                "only {} tools — the table failed to build and this guard \
                 would pass on an empty result",
                arr.len()
            );
            for tool in arr {
                let name = tool["name"].as_str().expect("tool has a name");
                let ann = tool.get("annotations").unwrap_or_else(|| {
                    panic!(
                        "`{name}` is advertised with no annotations — add it \
                         to TOOL_ATTRS and decide what it does"
                    )
                });
                // All four hints present. A partial object is worse than
                // none: a client reading only `readOnlyHint` and finding
                // it absent may assume the default.
                for key in [
                    "readOnlyHint",
                    "destructiveHint",
                    "idempotentHint",
                    "openWorldHint",
                ] {
                    assert!(ann.get(key).is_some(), "`{name}` is missing {key}");
                }
            }
        }
    }

    /// The reverse direction: nothing in the table is stale.
    ///
    /// `every_advertised_tool_is_classified` checks advertised →
    /// classified. A tool DELETED from the server leaves its entry
    /// behind, and nothing noticed. Low harm on its own — but the next
    /// reader treats the table as the tool list, and a stale row makes
    /// it a worse answer than no table. `CONFIRM_STATE` in the safety
    /// tests already checks both directions; this is the same rule.
    #[test]
    fn no_table_entry_names_a_tool_that_is_gone() {
        let advertised: Vec<String> =
            super::super::tools::tool_table(&crate::cli::mcp::WriteScope::All, true)
                .as_array()
                .expect("array")
                .iter()
                .filter_map(|t| t["name"].as_str().map(str::to_string))
                .collect();
        assert!(
            advertised.len() >= 8,
            "the tool table failed to build; this guard would pass vacuously"
        );
        let stale: Vec<&str> = TOOL_ATTRS
            .iter()
            .map(|(n, _)| *n)
            .filter(|n| !advertised.iter().any(|a| a == n))
            .collect();
        assert!(
            stale.is_empty(),
            "TOOL_ATTRS classifies tools that are no longer advertised — \
             drop them: {stale:?}"
        );
    }

    #[test]
    fn the_local_read_is_not_marked_open_world() {
        // `audit_log` reads ebman's own log file and makes no AWS call.
        // Everything else here observes an account whose contents move
        // without us. Getting this wrong is a small lie in the
        // direction of "might reach anywhere", and a hint is worth less
        // every time it is wrong.
        assert!(!attrs_for("audit_log").expect("classified").open_world);
        for aws_backed in ["list_environments", "drift", "fleet_cost", "lint"] {
            assert!(
                attrs_for(aws_backed).expect(aws_backed).open_world,
                "`{aws_backed}` reaches AWS and should say so"
            );
        }
    }

    #[test]
    fn reads_are_marked_read_only_and_writes_are_not() {
        // The distinction the whole feature exists for. Asserted over
        // the real tables in both modes, so a tool moving between them
        // cannot keep a stale hint.
        let reads = super::super::tools::tool_table(&crate::cli::mcp::WriteScope::None, true);
        for tool in reads.as_array().expect("array") {
            let name = tool["name"].as_str().expect("name");
            assert_eq!(
                tool["annotations"]["readOnlyHint"], true,
                "`{name}` is in the read-only table but is not marked read-only"
            );
        }

        let with_writes = super::super::tools::tool_table(&crate::cli::mcp::WriteScope::All, true);
        let write_names: Vec<&str> = with_writes
            .as_array()
            .expect("array")
            .iter()
            .filter(|t| t["annotations"]["readOnlyHint"] == false)
            .map(|t| t["name"].as_str().expect("name"))
            .collect();
        assert!(
            write_names.len() >= 5,
            "expected the write surface to be marked; got {write_names:?}"
        );
        // And enabling writes only ADDS: no read tool changed its hint.
        assert_eq!(
            with_writes.as_array().expect("array").len(),
            reads.as_array().expect("array").len() + write_names.len()
        );
    }

    #[test]
    fn the_destructive_tools_are_the_ones_that_destroy_something() {
        // Named individually rather than counted. `terminate` and
        // `rebuild` destroy; `restart`, `deploy` and `set_option` are
        // reversible and must NOT be flagged, or the hint means nothing
        // and a client learns to ignore it.
        for t in ["terminate", "rebuild"] {
            assert!(
                attrs_for(t).expect(t).destructive,
                "`{t}` destroys something and must say so"
            );
        }
        for t in ["restart", "deploy", "set_option"] {
            assert!(
                !attrs_for(t).expect(t).destructive,
                "`{t}` is reversible; flagging it teaches clients to ignore \
                 the hint on the ones that are not"
            );
        }
    }

    #[test]
    fn confirm_action_is_annotated_at_its_worst_case() {
        // It dispatches whatever is pending, which may be a terminate.
        // A client trusting `destructive: false` here would skip the
        // consent prompt on precisely the call that most needs one.
        let a = attrs_for("confirm_action").expect("classified");
        assert!(!a.read_only, "it dispatches a write");
        assert!(a.destructive, "it may dispatch a terminate");
        // Single-use token: confirming twice is not confirming once.
        assert!(!a.idempotent);
    }

    #[test]
    fn an_unclassified_tool_gets_no_annotations_rather_than_wrong_ones() {
        // A wrong hint is worse than an absent one — a client that
        // trusts `readOnlyHint: true` on a write will not ask before
        // dispatching it. So the lookup returns None rather than a
        // default.
        assert!(annotations_for("no_such_tool").is_none());
        assert!(attrs_for("no_such_tool").is_none());
    }
}