coding_tools/allowlist.rs
1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! `ct-test`'s command allow-gate.
5//!
6//! `ct-test` can run an arbitrary program, so it runs **only** commands on a
7//! fixed, compiled-in list of read-only commands ([`BUILTIN`]). The list is
8//! intentionally **static and immutable**: nothing a caller does at run time can
9//! extend it, so an agent driving `ct-test` cannot grant itself new commands. A
10//! command that is not on the list is refused, and nothing runs.
11//!
12//! Gating is by **program name** (the file-name component of `--cmd`, or `sh`
13//! under `--shell`, since a shell line can run anything). It is a guard against
14//! unintended side effects, not a sandbox: it does not inspect arguments or
15//! resolve which binary a name ultimately runs.
16
17use std::path::Path;
18
19/// Commands trusted as read-only — the entire, fixed allowlist.
20///
21/// Deliberately small and conservative: names whose ordinary use has no side
22/// effects. (`find` is excluded: `-delete`/`-exec` make it not read-only; the
23/// umbrella `ct` and the mutating `ct-test`/`ct-edit`/`ct-patch` are excluded
24/// because they can change state — the read-only `ct-search`, `ct-tree`, and
25/// `ct-view` are included.) There is no run-time mechanism to add to this list.
26pub const BUILTIN: &[&str] = &[
27 "cat",
28 "ct-search",
29 "ct-tree",
30 "ct-view",
31 "echo",
32 "false",
33 "file",
34 "grep",
35 "head",
36 "ls",
37 "pwd",
38 "stat",
39 "tail",
40 "true",
41 "wc",
42];
43
44/// The program name the gate checks for a given `--cmd` / `--shell` pairing.
45///
46/// Under `--shell` the program is always `sh` (the shell line itself is opaque);
47/// otherwise it is the file-name component of `cmd`, so `ls`, `/bin/ls`, and
48/// `./ls` all gate on `ls`.
49///
50/// # Examples
51///
52/// ```
53/// use coding_tools::allowlist::gated_name;
54///
55/// assert_eq!(gated_name("/bin/ls", false), "ls");
56/// assert_eq!(gated_name("./parse", false), "parse");
57/// assert_eq!(gated_name("grep x | wc -l", true), "sh"); // shell line gates on sh
58/// ```
59pub fn gated_name(cmd: &str, shell: bool) -> String {
60 if shell {
61 return "sh".to_string();
62 }
63 Path::new(cmd)
64 .file_name()
65 .map(|n| n.to_string_lossy().into_owned())
66 .filter(|n| !n.is_empty())
67 .unwrap_or_else(|| cmd.to_string())
68}
69
70/// Whether `name` is on the fixed allowlist.
71///
72/// # Examples
73///
74/// ```
75/// use coding_tools::allowlist::is_allowed;
76///
77/// assert!(is_allowed("grep")); // a built-in read-only command
78/// assert!(is_allowed("ct-search")); // the suite's own read-only tools
79/// assert!(!is_allowed("rm")); // not read-only, never runnable
80/// assert!(!is_allowed("sh")); // shell is excluded, so --shell is gated off
81/// ```
82pub fn is_allowed(name: &str) -> bool {
83 BUILTIN.contains(&name)
84}
85
86#[cfg(test)]
87mod tests {
88 use super::*;
89
90 #[test]
91 fn gated_name_uses_basename_or_sh() {
92 assert_eq!(gated_name("ls", false), "ls");
93 assert_eq!(gated_name("/bin/ls", false), "ls");
94 assert_eq!(gated_name("./parse", false), "parse");
95 assert_eq!(gated_name("anything --here", true), "sh");
96 }
97
98 #[test]
99 fn builtins_allowed_everything_else_refused() {
100 assert!(is_allowed("grep"));
101 assert!(is_allowed("ct-search"));
102 // Not read-only, never runnable, and unextendable at run time.
103 assert!(!is_allowed("parse"));
104 assert!(!is_allowed("sh"));
105 assert!(!is_allowed("ct-edit"));
106 }
107}