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//! * `ct-test` gates on [`BUILTIN`]: read-only commands only.
14//! * `ct-each` gates through [`is_allowed_for_each`]: [`BUILTIN`] plus
15//! `ct-test` (itself gated, so still read-only), and — only behind an
16//! explicit `--mutating` flag — the suite's own [`MUTATING_SUITE`] tools,
17//! which carry their own `--expect`/`--dry-run` safety gates.
18//!
19//! Gating is by **program name** (the file-name component of the command). It
20//! is a guard against unintended side effects, not a sandbox: it does not
21//! inspect arguments or resolve which binary a name ultimately runs.
22
23use std::path::Path;
24
25/// Commands trusted as read-only — `ct-test`'s entire, fixed allowlist.
26///
27/// Deliberately small and conservative: names whose ordinary use has no side
28/// effects. (`find` is excluded: `-delete`/`-exec` make it not read-only; the
29/// umbrella `ct` and the dispatching/mutating `ct-test`/`ct-each`/`ct-edit`/
30/// `ct-patch`/`ct-rules`/`ct-await` are excluded because they can change
31/// state or dispatch — the read-only `ct-search`, `ct-outline`, `ct-tree`,
32/// `ct-view`, `ct-check`, and `ct-deps` (whose `cargo metadata` source is
33/// forced `--locked --offline`) are included.) There is no run-time
34/// mechanism to add to this list.
35pub const BUILTIN: &[&str] = &[
36 "cat",
37 "ct-check",
38 "ct-deps",
39 "ct-outline",
40 "ct-search",
41 "ct-tree",
42 "ct-view",
43 "echo",
44 "false",
45 "file",
46 "grep",
47 "head",
48 "ls",
49 "pwd",
50 "stat",
51 "tail",
52 "true",
53 "wc",
54];
55
56/// The suite's mutating tools, runnable by `ct-each` only behind its explicit
57/// `--mutating` flag. Each carries its own `--expect`/`--dry-run` gates, so a
58/// dispatched edit still has to assert its own effect before writing.
59pub const MUTATING_SUITE: &[&str] = &["ct-edit", "ct-patch"];
60
61/// The program name the gates check for a command: its file-name component,
62/// so `ls`, `/bin/ls`, and `./ls` all gate on `ls`.
63///
64/// # Examples
65///
66/// ```
67/// use coding_tools::allowlist::gated_name;
68///
69/// assert_eq!(gated_name("/bin/ls"), "ls");
70/// assert_eq!(gated_name("./parse"), "parse");
71/// ```
72pub fn gated_name(cmd: &str) -> String {
73 Path::new(cmd)
74 .file_name()
75 .map(|n| n.to_string_lossy().into_owned())
76 .filter(|n| !n.is_empty())
77 .unwrap_or_else(|| cmd.to_string())
78}
79
80/// Whether `name` is on `ct-test`'s fixed read-only allowlist.
81///
82/// # Examples
83///
84/// ```
85/// use coding_tools::allowlist::is_allowed;
86///
87/// assert!(is_allowed("grep")); // a built-in read-only command
88/// assert!(is_allowed("ct-search")); // the suite's own read-only tools
89/// assert!(!is_allowed("rm")); // not read-only, never runnable
90/// assert!(!is_allowed("sh")); // no shell, ever
91/// ```
92pub fn is_allowed(name: &str) -> bool {
93 BUILTIN.contains(&name)
94}
95
96/// Whether `name` is a permitted `ct-each` dispatch target.
97///
98/// The base set is [`BUILTIN`] plus `ct-test` (which only runs read-only
99/// commands itself, so dispatching it stays read-only). With `mutating`, the
100/// suite's [`MUTATING_SUITE`] tools are also permitted — and nothing else:
101/// arbitrary mutating commands are never runnable.
102///
103/// # Examples
104///
105/// ```
106/// use coding_tools::allowlist::is_allowed_for_each;
107///
108/// assert!(is_allowed_for_each("ct-view", false));
109/// assert!(is_allowed_for_each("ct-test", false)); // itself gated read-only
110/// assert!(!is_allowed_for_each("ct-edit", false)); // needs --mutating
111/// assert!(is_allowed_for_each("ct-edit", true));
112/// assert!(!is_allowed_for_each("rm", true)); // never, even with --mutating
113/// assert!(!is_allowed_for_each("sh", true)); // no shell, ever
114/// ```
115pub fn is_allowed_for_each(name: &str, mutating: bool) -> bool {
116 is_allowed(name) || name == "ct-test" || (mutating && MUTATING_SUITE.contains(&name))
117}
118
119#[cfg(test)]
120mod tests {
121 use super::*;
122
123 #[test]
124 fn gated_name_uses_basename() {
125 assert_eq!(gated_name("ls"), "ls");
126 assert_eq!(gated_name("/bin/ls"), "ls");
127 assert_eq!(gated_name("./parse"), "parse");
128 }
129
130 #[test]
131 fn builtins_allowed_everything_else_refused() {
132 assert!(is_allowed("grep"));
133 assert!(is_allowed("ct-search"));
134 // Not read-only, never runnable, and unextendable at run time.
135 assert!(!is_allowed("parse"));
136 assert!(!is_allowed("sh"));
137 assert!(!is_allowed("ct-edit"));
138 assert!(!is_allowed("ct-each"));
139 }
140
141 #[test]
142 fn each_gate_extends_only_to_suite_tools() {
143 assert!(is_allowed_for_each("grep", false));
144 assert!(is_allowed_for_each("ct-test", false));
145 assert!(!is_allowed_for_each("ct-each", false)); // no self-nesting
146 assert!(!is_allowed_for_each("ct-each", true));
147 assert!(!is_allowed_for_each("ct-edit", false));
148 assert!(is_allowed_for_each("ct-patch", true));
149 assert!(!is_allowed_for_each("mvn", true)); // external commands never
150 }
151}