trusty-mpm 1.8.2

trusty-mpm: unified multi-agent orchestration platform (core, daemon, CLI, TUI, Telegram)
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
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
//! `tm hook --pm-guard` — a `gh api` DELETE of a GitHub secret (#8875, the
//! #8869 design's `gh api -X DELETE` DENY row), and the other calls that
//! delete one: `gh secret delete`, a `gh alias` that runs a DELETE, and a
//! `curl` DELETE of a `secrets` path.
//!
//! Why: the read rule judges a path by its basename, and in
//! `repos/O/R/actions/secrets/NAME` that is the secret's name, which is not a
//! secret-shaped word, so the rule never looked. The call deletes a repo's,
//! org's or environment's Actions, Dependabot or Codespaces secret — damage,
//! not a leak — so it is judged by method and endpoint, not by the file class.
//! What: [`evaluate_gh_api_secret_delete`]. A child of
//! `pm_guard_secret_consumers` (split out for the 500-SLOC cap), so it shares
//! that module's redirect reading.
//! Test: `denies_a_gh_api_delete_of_any_secret_8875`,
//! `keeps_the_get_listing_and_literal_non_secret_deletes_8875`,
//! `denies_a_delete_whose_endpoint_the_guard_cannot_read_8875`,
//! `denies_the_round_three_forms_8875`,
//! `every_gh_api_delete_arm_fails_closed_8875`; end to end,
//! `pm_guard_denies_a_gh_api_delete_of_a_secret_8875`.

use super::is_redirect_shaped;
use crate::commands::pm_guard_bash::{split_shell_segments, tokenize};
use crate::commands::pm_guard_secret_read::command_basename;

/// `gh api` switches: they take no value (pflag also accepts `--name=bool`).
const GH_API_SWITCHES: &[&str] = &[
    "--include",
    "--paginate",
    "--silent",
    "--slurp",
    "--verbose",
    "--help",
];

/// `gh api` long options that take one value.
const GH_API_VALUED: &[&str] = &[
    "--method",
    "--header",
    "--raw-field",
    "--field",
    "--input",
    "--jq",
    "--template",
    "--cache",
    "--preview",
    "--hostname",
];

/// `curl` short options that take a value (the value is the rest of the word
/// or the next word), besides `-X`.
const CURL_VALUED_SHORT: &[char] = &[
    'A', 'b', 'c', 'C', 'd', 'D', 'e', 'E', 'F', 'H', 'K', 'm', 'o', 'P', 'Q', 'r', 't', 'T', 'u',
    'U', 'w', 'x', 'y', 'Y', 'z',
];

/// `curl` long options that take a value, besides `--request` and `--url`.
const CURL_VALUED_LONG: &[&str] = &[
    "header",
    "data",
    "data-raw",
    "data-binary",
    "data-urlencode",
    "data-ascii",
    "json",
    "user",
    "output",
    "user-agent",
    "referer",
    "cookie",
    "cookie-jar",
    "form",
    "write-out",
    "proxy",
    "oauth2-bearer",
    "max-time",
    "connect-timeout",
    "config",
    "cert",
    "key",
    "cacert",
    "retry",
    "upload-file",
    "dump-header",
];

/// Programs whose operands are a command text of their own (`ssh host 'cmd'`,
/// `eval 'cmd'`), as the sibling credential rule reads them.
const OPERAND_RUNNERS: &[&str] = &["ssh", "eval"];

/// Re-judge nesting (`ssh`/`eval` operands, `$S -c`, a shell alias) the rule
/// follows before refusing — the `credential_print` cap.
const MAX_DEPTH: usize = 8;

/// Refuse a call that DELETEs a GitHub secret: `Some(reason)` denies.
///
/// Why: see the module doc. A pre-filter on the word `secrets` failed open
/// (#8875 round 2): `$'…sec\x72ets…'`, `sec{r..r}ets`, an endpoint piped in by
/// `xargs`, or an `$EP` bound in an earlier call name no `secrets` word, yet
/// the shell hands gh a secrets path. Round 3 (owner ruling): an unlexable
/// call is refused outright, with no word heuristic to slip past.
/// What: each segment that runs `gh api` — the program `gh` by basename or a
/// word the shell rewrites (`$G`, `g[h]`), then `api` or a rewritten word
/// (`$A`, `{api,}`) — is read the way gh's pflag parser reads it (`-X DELETE`,
/// `-XDELETE`, `-X=DELETE`, `--method[=]DELETE`, any case, before or after the
/// endpoint). A DELETE, or a method the guard cannot read literally, denies
/// when its endpoint is absent, repeated or not literal, and — for one literal
/// endpoint — when that endpoint names a `secrets` word. A literal non-secret
/// DELETE and every literal non-DELETE pass, so the #8869 GET listing still
/// does. Also refused: `gh secret delete|remove`, a `gh alias set` whose
/// expansion is an `api` DELETE or a `secret delete`, `gh alias import`, and a
/// `curl` DELETE of a `secrets` URL or one it cannot read. `ssh`/`eval`
/// operands and a non-literal shell's `-c` text are judged as commands of
/// their own, up to [`MAX_DEPTH`] deep.
///
/// Fail closed: an unlexable segment that starts one of these calls denies; a
/// `gh api` call with an unknown option or a flag missing its value denies
/// when the command names `secrets` or the call carries a `delete` word or an
/// expansion; nesting past [`MAX_DEPTH`] denies.
/// Test: `denies_a_gh_api_delete_of_any_secret_8875`,
/// `denies_a_delete_whose_endpoint_the_guard_cannot_read_8875`,
/// `denies_the_round_three_forms_8875`,
/// `keeps_the_get_listing_and_literal_non_secret_deletes_8875`,
/// `every_gh_api_delete_arm_fails_closed_8875`.
pub(crate) fn evaluate_gh_api_secret_delete(command: &str) -> Option<String> {
    judge(command, 0).then(deny_reason)
}

/// Whether `command`, reached `depth` re-judges deep, deletes a secret.
fn judge(command: &str, depth: usize) -> bool {
    // #8875 round 3: past the cap the guard cannot see the call.
    if depth > MAX_DEPTH {
        return true;
    }
    let secrets_named = names_secrets(command);
    split_shell_segments(command)
        .iter()
        .any(|segment| segment_deletes_a_secret(segment, secrets_named, depth))
}

/// Whether one segment runs a call the guard must refuse.
fn segment_deletes_a_secret(segment: &str, secrets_named: bool, depth: usize) -> bool {
    let Ok(argv) = tokenize(segment) else {
        // #8875 round 3 (owner ruling): an unlexable call is refused outright.
        return starts_a_watched_call(segment);
    };
    reruns_a_refused_command(&argv, depth)
        || gh_calls(&argv, "api").any(|(_, tail)| gh_api_deletes(tail, secrets_named))
        || gh_calls(&argv, "secret").any(|(sub, tail)| secret_subcommand_deletes(sub, tail))
        || gh_calls(&argv, "alias").any(|(sub, tail)| alias_defines_a_delete(sub, tail, depth))
        || argv
            .iter()
            .enumerate()
            .any(|(at, w)| possibly_named(w, "curl") && curl_deletes_a_secret(&argv[at + 1..]))
}

/// Whether an unlexable segment starts a `gh api|secret|alias` or `curl` call,
/// read with quotes and backslashes both removed and turned into spaces.
/// #9001: a word the shell rewrites (`$C$R`, `c?rl`) counts as `curl` when
/// the segment names a DELETE of `secrets` — deny-only, closing a gap that
/// predates #9001.
/// Test: `a_heredoc_body_that_spells_gh_or_curl_still_denies_9001`,
/// `a_rewritten_word_without_a_secrets_delete_is_not_a_curl_call_9001`.
fn starts_a_watched_call(segment: &str) -> bool {
    let rewritten_delete =
        names_secrets(segment) && segment.to_ascii_lowercase().contains("delete");
    [
        segment.replace(['\'', '"', '\\'], ""),
        segment.replace(['\'', '"', '\\'], " "),
    ]
    .iter()
    .any(|flat| {
        let words: Vec<&str> = flat.split_whitespace().collect();
        words
            .iter()
            .any(|w| command_basename(w) == "curl" || (rewritten_delete && is_rewritten(w)))
            || words.windows(2).any(|w| {
                ["api", "secret", "alias"]
                    .iter()
                    .any(|sub| possibly_named(w[0], "gh") && possibly_sub(w[1], sub))
            })
    })
}

/// `ssh`/`eval` operands, and a non-literal shell's `-c` text (`$S -c '…'`),
/// judged as a command of their own.
fn reruns_a_refused_command(argv: &[String], depth: usize) -> bool {
    // #8875 round 2: `ssh host 'gh api …'` runs its operands as a command.
    let runner = argv
        .iter()
        .position(|w| OPERAND_RUNNERS.contains(&command_basename(w).as_str()));
    if runner.is_some_and(|at| judge(&argv[at + 1..].join(" "), depth + 1)) {
        return true;
    }
    // #8875 round 3: a shell named by an expansion is no `sh -c` wrapper the
    // segment splitter knows.
    argv.windows(3).any(|w| {
        let c_flag = w[1].starts_with('-') && !w[1].starts_with("--") && w[1].contains('c');
        is_rewritten(&w[0]) && c_flag && judge(&w[2], depth + 1)
    })
}

/// The subcommand word and the argv tail after each `gh <sub>` pair in `argv`.
fn gh_calls<'a>(argv: &'a [String], sub: &'a str) -> impl Iterator<Item = (&'a str, &'a [String])> {
    argv.windows(2)
        .enumerate()
        .filter(move |(_, w)| possibly_named(&w[0], "gh") && possibly_sub(&w[1], sub))
        .map(move |(at, w)| (w[1].as_str(), &argv[at + 2..]))
}

/// Whether a program word could run `name`: by basename, or a word the shell
/// rewrites into it (`$G`, `` `which gh` ``, `g[h]`, `/opt/homebrew/bin/g[h]`).
fn possibly_named(program: &str, name: &str) -> bool {
    command_basename(program) == name || could_rewrite_to(program, name)
}

/// Whether a subcommand word could be `sub`: literally, or rewritten (`$A`,
/// `{api,}`).
fn possibly_sub(word: &str, sub: &str) -> bool {
    word == sub || could_rewrite_to(word, sub)
}

/// Whether the shell's rewrite of `word` could produce `name` (#9006).
///
/// Why: a Python subscript pair such as `d[k] x[0]` in a here-document body
/// read as a rewritten `gh api`, so an inert edit script was refused as a
/// secret DELETE. An expansion, a backtick or a brace can produce any text,
/// but a word whose only rewrite is a glob becomes `name` only by matching it.
/// What: `false` for a word the shell leaves alone; `true` for an expansion,
/// a backtick or a brace; otherwise [`glob_could_match`] on the basename.
/// Test: `a_python_subscript_pair_is_no_gh_api_call_9006`,
/// `a_glob_that_can_spell_gh_still_denies_9006`.
fn could_rewrite_to(word: &str, name: &str) -> bool {
    if !is_rewritten(word) {
        return false;
    }
    word.contains(['$', '`', '{', '}']) || glob_could_match(&command_basename(word), name)
}

/// Whether the glob `pattern` could match the file name `name` (#9006).
///
/// What: deliberately over-approximate. A bracket class stands for any one
/// byte, case is folded (`nocaseglob`), and a pattern carrying any byte
/// outside letters, digits, `.`, `_`, `-`, `*`, `?` and a balanced `[…]` —
/// zsh's `(a|b)`, `<1-9>`, `^`, `#`, `~` — answers `true`, so syntax this
/// reader does not model keeps the deny.
/// Test: `a_glob_that_can_spell_gh_still_denies_9006`.
fn glob_could_match(pattern: &str, name: &str) -> bool {
    let mut tokens: Vec<Option<Option<u8>>> = Vec::new();
    let bytes = pattern.as_bytes();
    let mut i = 0;
    while let Some(&b) = bytes.get(i) {
        match b {
            // A run of `*` is one `*`, which keeps the match linear in stars.
            b'*' if tokens.last() == Some(&None) => {}
            b'*' => tokens.push(None),
            b'?' => tokens.push(Some(None)),
            b'[' => match bytes[i + 1..].iter().position(|&c| c == b']') {
                Some(close) if close > 0 => {
                    tokens.push(Some(None));
                    i += close + 1;
                }
                _ => return true,
            },
            c if c.is_ascii_alphanumeric() || matches!(c, b'.' | b'_' | b'-') => {
                tokens.push(Some(Some(c.to_ascii_lowercase())));
            }
            _ => return true,
        }
        i += 1;
    }
    // Each non-`*` token takes one byte, so more of them than `name` has
    // bytes cannot match.
    if tokens.iter().filter(|t| t.is_some()).count() > name.len() {
        return false;
    }
    wildcard_match(&tokens, name.to_ascii_lowercase().as_bytes())
}

/// Match `tokens` — `None` for `*`, `Some(None)` for any one byte,
/// `Some(Some(b))` for the byte `b` — against all of `text`.
fn wildcard_match(tokens: &[Option<Option<u8>>], text: &[u8]) -> bool {
    match tokens.split_first() {
        None => text.is_empty(),
        Some((None, rest)) => (0..=text.len()).any(|skip| wildcard_match(rest, &text[skip..])),
        Some((Some(atom), rest)) => text.split_first().is_some_and(|(&b, tail)| {
            atom.is_none_or(|want| want == b) && wildcard_match(rest, tail)
        }),
    }
}

/// Whether the shell rewrites a word before the program sees it: an
/// expansion, a glob or a brace.
fn is_rewritten(word: &str) -> bool {
    word.contains(['$', '`', '*', '?', '[', ']', '{', '}'])
}

/// Whether a `gh api` argv tail DELETEs a secret, or one the guard cannot read.
fn gh_api_deletes(tail: &[String], secrets_named: bool) -> bool {
    let Some((methods, endpoints)) = parse_gh_api(tail) else {
        // #8875: gh rejects this argv, so only a suspicious one is refused.
        let words: Vec<&str> = tail.iter().map(String::as_str).collect();
        return secrets_named || suspicious(&words);
    };
    maybe_delete(&methods)
        && match endpoints.as_slice() {
            [endpoint] if is_literal_endpoint(endpoint) => names_secrets(endpoint),
            // #8875 round 2: an endpoint from xargs/stdin, several, or one the
            // shell rewrites could be a secrets path.
            _ => true,
        }
}

/// Whether one of `methods` is DELETE, or a value the guard cannot read.
fn maybe_delete(methods: &[&str]) -> bool {
    methods
        .iter()
        .any(|m| m.eq_ignore_ascii_case("DELETE") || !is_literal_method(m))
}

/// #8875 round 3: whether a `gh secret` argv tail deletes: any `delete` or
/// `remove` word (the subcommand and its alias, wherever the `-R`/`--env`/
/// `--org` flags sit), or — after a literal `secret` — a rewritten first
/// operand. A rewritten `sub` (`$A $B $C`, a nested `$(echo …`) needs the word.
fn secret_subcommand_deletes(sub: &str, tail: &[String]) -> bool {
    tail.iter()
        .any(|w| matches!(w.as_str(), "delete" | "remove"))
        || (sub == "secret"
            && tail
                .iter()
                .find(|w| !w.starts_with('-'))
                .is_some_and(|w| is_rewritten(w)))
}

/// #8875 round 3: whether a `gh alias` argv tail defines an alias that deletes,
/// or one whose definition the guard cannot read (`import`, `set NAME -`, or a
/// rewritten verb after a literal `alias`).
fn alias_defines_a_delete(sub: &str, tail: &[String], depth: usize) -> bool {
    let shell = tail
        .iter()
        .any(|w| w == "--shell" || (w.starts_with('-') && !w.starts_with("--") && w.contains('s')));
    let words: Vec<&str> = tail
        .iter()
        .map(String::as_str)
        .filter(|w| *w == "-" || !w.starts_with('-'))
        .collect();
    match words.as_slice() {
        ["import", ..] | ["set", _, "-", ..] => true,
        [verb, ..] if sub == "alias" && is_rewritten(verb) => true,
        ["set", _, expansion, ..] => expansion_deletes(expansion, shell, depth),
        _ => false,
    }
}

/// Whether a `gh alias set` expansion runs a DELETE: a shell alias (`!…` or
/// `--shell`) judged as a command, an `api` call with a DELETE or unreadable
/// method, or a `secret delete|remove`.
fn expansion_deletes(expansion: &str, shell: bool, depth: usize) -> bool {
    if let Some(script) = expansion.strip_prefix('!').or(shell.then_some(expansion)) {
        return judge(script, depth + 1);
    }
    let Ok(words) = tokenize(expansion) else {
        return true;
    };
    let secret_delete = words
        .windows(2)
        .any(|w| w[0] == "secret" && matches!(w[1].as_str(), "delete" | "remove"));
    let api_delete = words.first().is_some_and(|w| w == "api")
        && parse_gh_api(&words[1..]).is_none_or(|(methods, _)| maybe_delete(&methods));
    secret_delete || api_delete
}

/// #8875 round 3: whether a `curl` argv tail DELETEs a `secrets` URL, or a URL
/// the guard cannot read (absent, or one the shell or curl's own globbing
/// rewrites). Another option's value that lands among the URLs is harmless:
/// it names no `secrets` word.
fn curl_deletes_a_secret(tail: &[String]) -> bool {
    let mut methods = Vec::new();
    let mut urls = Vec::new();
    let mut i = 0;
    while let Some(tok) = tail.get(i) {
        i += 1;
        let mut take = |joined: Option<&'_ str>| match joined {
            Some(v) => v.to_string(),
            None => {
                i += 1;
                tail.get(i - 1).cloned().unwrap_or_default()
            }
        };
        if let Some(long) = tok.strip_prefix("--") {
            let (name, joined) = long
                .split_once('=')
                .map_or((long, None), |(n, v)| (n, Some(v)));
            match name {
                "request" => methods.push(take(joined)),
                "url" => urls.push(take(joined)),
                _ if CURL_VALUED_LONG.contains(&name) && joined.is_none() => i += 1,
                _ => {}
            }
        } else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.is_empty()) {
            let valued = cluster
                .char_indices()
                .find(|(_, c)| *c == 'X' || CURL_VALUED_SHORT.contains(c));
            if let Some((k, letter)) = valued {
                let rest = &cluster[k + 1..];
                let value = take((!rest.is_empty()).then_some(rest));
                if letter == 'X' {
                    methods.push(value);
                }
            }
        } else {
            urls.push(tok.clone());
        }
    }
    let methods: Vec<&str> = methods.iter().map(String::as_str).collect();
    maybe_delete(&methods)
        && (urls.is_empty()
            || urls
                .iter()
                .any(|u| names_secrets(u) || is_rewritten(u) || u.starts_with('~')))
}

/// Whether an unreadable call carries a `delete` word or an expansion.
fn suspicious(words: &[&str]) -> bool {
    words
        .iter()
        .any(|w| w.to_ascii_lowercase().contains("delete") || w.contains(['$', '`']))
}

/// The method values and the endpoint words of a `gh api` argv tail, read as
/// pflag reads it; `None` for an unknown option or a flag missing its value.
fn parse_gh_api(args: &[String]) -> Option<(Vec<&str>, Vec<&str>)> {
    let mut methods = Vec::new();
    let mut endpoints = Vec::new();
    let mut i = 0;
    while i < args.len() {
        let tok = args[i].as_str();
        i += 1;
        if tok == "--" {
            endpoints.extend(args[i..].iter().map(String::as_str));
            break;
        }
        if is_redirect_shaped(tok) {
            // A bare operator (`>`, `2>`, `<`) takes the next word as its file.
            i += usize::from(tok.ends_with(['<', '>']));
            continue;
        }
        let (is_method, joined) = if let Some(long) = tok.strip_prefix("--") {
            let (name, joined) = long
                .split_once('=')
                .map_or((long, None), |(n, v)| (n, Some(v)));
            let name = &tok[..name.len() + 2];
            if GH_API_SWITCHES.contains(&name) {
                continue;
            }
            GH_API_VALUED.contains(&name).then_some(())?;
            (name == "--method", joined)
        } else if let Some(cluster) = tok.strip_prefix('-').filter(|c| !c.is_empty()) {
            // `-i` is gh api's one short switch; the first valued letter takes
            // the rest of the word, past an optional `=`, as its value.
            let mut chars = cluster.trim_start_matches(['i', 'h']).chars();
            let Some(letter) = chars.next() else {
                continue;
            };
            matches!(letter, 'X' | 'H' | 'f' | 'F' | 'q' | 't' | 'p').then_some(())?;
            let rest = chars.as_str();
            let rest = rest.strip_prefix('=').unwrap_or(rest);
            (letter == 'X', (!rest.is_empty()).then_some(rest))
        } else {
            endpoints.push(tok);
            continue;
        };
        let value = match joined {
            Some(v) => v,
            None => {
                i += 1;
                args.get(i - 1)?.as_str()
            }
        };
        if is_method {
            methods.push(value);
        }
    }
    Some((methods, endpoints))
}

/// Whether a method value is a plain word; `"$M"` or `""` could be DELETE.
fn is_literal_method(method: &str) -> bool {
    !method.is_empty() && method.bytes().all(|b| b.is_ascii_alphabetic())
}

/// Whether `text`, de-quoted, percent-decoded and lower-cased, carries the word
/// `secrets` between non-alphanumeric bytes (`…/secrets/N`, `organization-secrets`).
fn names_secrets(text: &str) -> bool {
    let decoded = percent_decode(&text.replace(['\'', '"', '\\'], "")).to_ascii_lowercase();
    decoded
        .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
        .any(|word| word == "secrets")
}

/// `text` with each `%XX` escape decoded; a malformed escape is kept as is.
fn percent_decode(text: &str) -> String {
    let bytes = text.as_bytes();
    let mut out = Vec::with_capacity(bytes.len());
    let mut i = 0;
    while i < bytes.len() {
        let hex = bytes
            .get(i + 1..i + 3)
            .and_then(|h| std::str::from_utf8(h).ok());
        match hex
            .filter(|_| bytes[i] == b'%')
            .and_then(|h| u8::from_str_radix(h, 16).ok())
        {
            Some(b) => {
                out.push(b);
                i += 3;
            }
            None => {
                out.push(bytes[i]);
                i += 1;
            }
        }
    }
    String::from_utf8_lossy(&out).into_owned()
}

/// Whether an endpoint word reaches gh as written: no expansion (`$`, `$'…'`,
/// backtick), no glob, no brace expansion other than gh's `{owner}`/`{repo}`/
/// `{branch}` placeholders, no leading `~`, and a `?` only as a query mark.
fn is_literal_endpoint(endpoint: &str) -> bool {
    let unplaced = ["{owner}", "{repo}", "{branch}"]
        .iter()
        .fold(endpoint.to_string(), |text, p| text.replace(p, ""));
    let query_ok = endpoint
        .split_once('?')
        .is_none_or(|(_, query)| !query.contains(['/', '?']));
    query_ok
        && !endpoint.starts_with('~')
        && !unplaced.contains(['$', '`', '*', '[', ',', '{', '}'])
}

/// The #8875 refusal; it never echoes the command.
fn deny_reason() -> String {
    "a `gh api` DELETE of a GitHub secret (`…/secrets/NAME` of a repo, org, user or \
     environment, Actions, Dependabot or Codespaces) is refused (issue #8875) — it destroys a \
     secret other workflows depend on. A GET that lists secret names is still allowed (issue \
     #8869). A `gh api` DELETE whose endpoint this guard cannot read literally (a variable, \
     `$'…'`, a brace or glob, or one fed in by `xargs`), or whose method it cannot read, is \
     refused too; write the endpoint out literally. So are `gh secret delete`, a `gh alias` \
     that runs a DELETE, a `curl` DELETE of a secrets URL, and a `gh api`, `gh secret`, \
     `gh alias` or `curl` call this guard cannot parse. Ask the operator to delete a secret."
        .to_string()
}

#[cfg(test)]
#[path = "pm_guard_secret_gh_api_delete_tests.rs"]
mod tests;