Skip to main content

coding_tools/
allowlist.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Jonathan Shook
3
4//! The command allow-gates behind the dispatching tools.
5//!
6//! `ct-test` and `ct-each` can run another program, so each runs **only**
7//! commands on a fixed, compiled-in list. The lists are intentionally **static
8//! and immutable**: nothing a caller does at run time can extend them, so an
9//! agent driving these tools cannot grant itself new commands. A command that
10//! is not on the relevant list is refused, and nothing runs. There is no shell
11//! mode anywhere in the suite — every dispatch is a direct argv launch.
12//!
13//! The allowlist is **platform-aware**, so the tools are usable both on Unix /
14//! MSYS2 and on native Windows (no MSYS2 required): [`CORE`] is the suite's own
15//! read-only `ct-*` tools, present on every OS, and [`NATIVE`] adds the host
16//! OS's stock read-only utilities (coreutils on Unix; `findstr`/`where`/… on
17//! Windows). [`builtin`] is their union for the current platform. This changes
18//! *which names resolve per OS*, not the no-shell, direct-argv guarantee.
19//!
20//! * `ct-test` gates on [`builtin`]: read-only commands only.
21//! * `ct-each` gates through [`is_allowed_for_each`]: [`builtin`] plus
22//!   `ct-test` (itself gated, so still read-only), and — only behind an
23//!   explicit `--mutating` flag — the suite's own [`MUTATING_SUITE`] tools,
24//!   which carry their own `--expect`/`--dry-run` safety gates.
25//!
26//! Gating is by **program name** (the file-name component of the command, with
27//! a Windows executable suffix like `.exe` stripped). It is a guard against
28//! unintended side effects, not a sandbox: it does not inspect arguments or
29//! resolve which binary a name ultimately runs.
30
31use std::path::Path;
32
33/// The suite's own read-only tools — the cross-platform core of the allowlist,
34/// present and resolvable on every OS.
35///
36/// The mutating/dispatching tools (`ct-edit`/`ct-patch`/`ct-rules`/`ct-test`/
37/// `ct-each`) and the umbrella `ct` are excluded because they change state or
38/// dispatch; `ct-await` is included as a read-only **observer** (it only polls
39/// other read-only probes), which also lets it serve as a portable, bounded
40/// long-running command. The crate-/module-graph checks (`deps`/`mods`) are not
41/// dispatch targets — they are built-in checks the rule layer runs in-process.
42pub const CORE: &[&str] = &[
43    "ct-await",
44    "ct-check",
45    "ct-outline",
46    "ct-search",
47    "ct-tree",
48    "ct-view",
49];
50
51/// The host OS's stock read-only utilities, added to [`CORE`]. Deliberately
52/// small and conservative: names whose ordinary use has no side effects.
53/// (`find` is excluded: `-delete`/`-exec` make it not read-only.) There is no
54/// run-time mechanism to add to this list.
55#[cfg(unix)]
56pub const NATIVE: &[&str] = &[
57    "cat", "echo", "false", "file", "grep", "head", "ls", "pwd", "stat", "tail", "true", "wc",
58];
59/// Stock read-only programs that exist on a bare Windows install (real `.exe`s,
60/// launched directly — still no shell). `findstr` covers grep- and cat-style
61/// needs (it can read a file or stdin); `more`/`where`/`whoami`/`hostname` round
62/// out the read-only set.
63#[cfg(windows)]
64pub const NATIVE: &[&str] = &["findstr", "hostname", "more", "where", "whoami"];
65#[cfg(not(any(unix, windows)))]
66pub const NATIVE: &[&str] = &[];
67
68/// `ct-test`'s entire read-only allowlist for the current platform: the
69/// cross-platform [`CORE`] plus the OS's [`NATIVE`] utilities. Returned as an
70/// owned list so callers can `join`/iterate it in messages.
71pub fn builtin() -> Vec<&'static str> {
72    CORE.iter().chain(NATIVE).copied().collect()
73}
74
75/// The suite's mutating tools, runnable by `ct-each` only behind its explicit
76/// `--mutating` flag. Each carries its own `--expect`/`--dry-run` gates, so a
77/// dispatched edit still has to assert its own effect before writing.
78pub const MUTATING_SUITE: &[&str] = &["ct-edit", "ct-patch"];
79
80/// The program name the gates check for a command: its file-name component,
81/// so `ls`, `/bin/ls`, and `./ls` all gate on `ls`. On Windows a trailing
82/// executable suffix (`.exe`/`.com`/`.bat`/`.cmd`, case-insensitive) is
83/// stripped, so an absolute or sibling path like `...\ct-search.exe` gates as
84/// `ct-search`.
85///
86/// # Examples
87///
88/// ```
89/// use coding_tools::allowlist::gated_name;
90///
91/// assert_eq!(gated_name("/bin/ls"), "ls");
92/// assert_eq!(gated_name("./parse"), "parse");
93/// ```
94pub fn gated_name(cmd: &str) -> String {
95    let base = Path::new(cmd)
96        .file_name()
97        .map(|n| n.to_string_lossy().into_owned())
98        .filter(|n| !n.is_empty())
99        .unwrap_or_else(|| cmd.to_string());
100    strip_exe_suffix(&base)
101}
102
103/// Strip a Windows executable suffix from a program's file name. A no-op on
104/// non-Windows, where a file may legitimately be named e.g. `foo.exe`.
105#[cfg(windows)]
106fn strip_exe_suffix(name: &str) -> String {
107    const EXTS: &[&str] = &[".exe", ".com", ".bat", ".cmd"];
108    let lower = name.to_ascii_lowercase();
109    for ext in EXTS {
110        if lower.ends_with(ext) {
111            return name[..name.len() - ext.len()].to_string();
112        }
113    }
114    name.to_string()
115}
116#[cfg(not(windows))]
117fn strip_exe_suffix(name: &str) -> String {
118    name.to_string()
119}
120
121/// Whether `name` is on `ct-test`'s fixed read-only allowlist for the current
122/// platform ([`CORE`] plus the OS's [`NATIVE`] utilities).
123///
124/// # Examples
125///
126/// ```
127/// use coding_tools::allowlist::is_allowed;
128///
129/// assert!(is_allowed("ct-search"));  // a suite read-only tool, on every platform
130/// assert!(!is_allowed("rm"));        // not read-only, never runnable
131/// assert!(!is_allowed("sh"));        // no shell, ever
132/// ```
133pub fn is_allowed(name: &str) -> bool {
134    CORE.contains(&name) || NATIVE.contains(&name)
135}
136
137/// Whether `name` is a permitted `ct-each` dispatch target.
138///
139/// The base set is [`BUILTIN`] plus `ct-test` (which only runs read-only
140/// commands itself, so dispatching it stays read-only). With `mutating`, the
141/// suite's [`MUTATING_SUITE`] tools are also permitted — and nothing else:
142/// arbitrary mutating commands are never runnable.
143///
144/// # Examples
145///
146/// ```
147/// use coding_tools::allowlist::is_allowed_for_each;
148///
149/// assert!(is_allowed_for_each("ct-view", false));
150/// assert!(is_allowed_for_each("ct-test", false));  // itself gated read-only
151/// assert!(!is_allowed_for_each("ct-edit", false)); // needs --mutating
152/// assert!(is_allowed_for_each("ct-edit", true));
153/// assert!(!is_allowed_for_each("rm", true));       // never, even with --mutating
154/// assert!(!is_allowed_for_each("sh", true));       // no shell, ever
155/// ```
156pub fn is_allowed_for_each(name: &str, mutating: bool) -> bool {
157    is_allowed(name) || name == "ct-test" || (mutating && MUTATING_SUITE.contains(&name))
158}
159
160#[cfg(test)]
161mod tests {
162    use super::*;
163
164    #[test]
165    fn gated_name_uses_basename() {
166        assert_eq!(gated_name("ls"), "ls");
167        assert_eq!(gated_name("/bin/ls"), "ls");
168        assert_eq!(gated_name("./parse"), "parse");
169    }
170
171    #[test]
172    #[cfg(windows)]
173    fn gated_name_strips_windows_exe_suffix() {
174        assert_eq!(gated_name("C:\\tools\\ct-search.exe"), "ct-search");
175        assert_eq!(gated_name("findstr.exe"), "findstr");
176        assert_eq!(gated_name("Foo.CMD"), "Foo");
177        assert_eq!(gated_name("noext"), "noext");
178    }
179
180    #[test]
181    fn builtins_allowed_everything_else_refused() {
182        // The cross-platform core is allowed everywhere.
183        assert!(is_allowed("ct-search"));
184        assert!(is_allowed("ct-await"));
185        // A native utility for this platform.
186        #[cfg(unix)]
187        assert!(is_allowed("grep"));
188        #[cfg(windows)]
189        assert!(is_allowed("findstr"));
190        // Not read-only, never runnable, and unextendable at run time.
191        assert!(!is_allowed("parse"));
192        assert!(!is_allowed("sh"));
193        assert!(!is_allowed("ct-edit"));
194        assert!(!is_allowed("ct-each"));
195    }
196
197    #[test]
198    fn each_gate_extends_only_to_suite_tools() {
199        assert!(is_allowed_for_each("ct-search", false));
200        assert!(is_allowed_for_each("ct-test", false));
201        assert!(!is_allowed_for_each("ct-each", false)); // no self-nesting
202        assert!(!is_allowed_for_each("ct-each", true));
203        assert!(!is_allowed_for_each("ct-edit", false));
204        assert!(is_allowed_for_each("ct-patch", true));
205        assert!(!is_allowed_for_each("mvn", true)); // external commands never
206    }
207
208    #[test]
209    fn builtin_unions_core_and_native() {
210        let b = builtin();
211        assert!(b.contains(&"ct-search")); // core
212        assert!(b.iter().any(|n| NATIVE.contains(n))); // at least one native
213        assert!(!b.contains(&"rm"));
214    }
215}