返回 CodeWhale
enforced_readonly.rs
根目录 / crates / tui / src / tools / shell / tests / enforced_readonly.rs
1 use super::*;
2 use tempfile::tempdir;
3
4 #[test]
5 fn enforced_readonly_mode_is_an_execution_contract_and_preserves_plain_classification() {
6 let plain = json!({"command": "python3 -c 'print(1)'"});
7 let input = json!({"command": "python3 -c 'print(1)'", "read_only": true});
8 assert!(!agent_readonly_bash_input(&plain));
9 assert!(!LowercaseBashTool.is_read_only_for(&plain));
10 assert!(agent_readonly_bash_input(&input));
11 assert!(LowercaseBashTool.is_read_only_for(&input));
12 assert!(LowercaseBashTool.supports_parallel_for(&input));
13 assert_eq!(
14 LowercaseBashTool.input_schema()["properties"]["read_only"]["type"],
15 "boolean"
16 );
17 for invalid in [json!(null), json!("true"), json!(1)] {
18 let mut input = input.clone();
19 input["read_only"] = invalid;
20 assert!(contract_bash_legacy_input(&input).is_err());
21 assert!(!agent_readonly_bash_input(&input));
22 }
23 }
24
25 #[tokio::test]
26 async fn enforced_readonly_rejects_incompatible_shapes_before_running_anything() {
27 let tmp = tempdir().unwrap();
28 let context = ToolContext::new(tmp.path());
29 for (key, value) in [
30 ("action", json!("wait")),
31 ("background", json!(true)),
32 ("interactive", json!(true)),
33 ("tty", json!(true)),
34 ("stdin", json!("input")),
35 ("sandbox_permissions", json!("danger-full-access")),
36 ("justification", json!("please widen")),
37 ] {
38 let mut input = json!({"command": "touch should-not-exist", "read_only": true});
39 input[key] = value;
40 assert!(
41 exec_shell_input_agent_readonly_verdict(&input).is_err(),
42 "{input}"
43 );
44 let error = BashTool::new("Bash")
45 .execute(input, &context)
46 .await
47 .unwrap_err();
48 assert!(error.to_string().contains("incompatible"), "{error}");
49 assert!(!tmp.path().join("should-not-exist").exists());
50 }
51 }
52
53 #[test]
54 fn enforced_readonly_refuses_an_unenforced_or_writable_prepared_environment() {
55 let tmp = tempdir().unwrap();
56 let mut environment = ExecEnv {
57 command: vec!["must-not-run".into()],
58 cwd: tmp.path().into(),
59 env: HashMap::new(),
60 timeout: Duration::from_secs(1),
61 sandbox_type: SandboxType::None,
62 policy: ExecutionSandboxPolicy::ReadOnly,
63 };
64 assert!(require_native_readonly_execution(&environment).is_err());
65 #[cfg(target_os = "macos")]
66 {
67 environment.sandbox_type = SandboxType::MacosSeatbelt;
68 }
69 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
70 {
71 environment.sandbox_type = SandboxType::LinuxBubblewrap;
72 }
73 environment.policy = ExecutionSandboxPolicy::DangerFullAccess;
74 assert!(require_native_readonly_execution(&environment).is_err());
75 }
76
77 struct RefusingExternalBackend;
78 #[async_trait]
79 impl crate::sandbox::backend::SandboxBackend for RefusingExternalBackend {
80 fn kind(&self) -> crate::sandbox::backend::SandboxKind {
81 crate::sandbox::backend::SandboxKind::Unsupported
82 }
83 async fn exec(
84 &self,
85 _command: &str,
86 _env: &HashMap<String, String>,
87 ) -> Result<crate::sandbox::backend::SandboxOutput> {
88 panic!("enforced read-only must never reach an unattested external executor")
89 }
90 }
91
92 #[tokio::test]
93 async fn enforced_readonly_refuses_external_backend_without_dispatch() {
94 let tmp = tempdir().unwrap();
95 let mut context = ToolContext::new(tmp.path());
96 context.sandbox_backend = Some(std::sync::Arc::new(RefusingExternalBackend));
97 let error = LowercaseBashTool
98 .execute(
99 json!({"command": "touch should-not-exist", "read_only": true}),
100 &context,
101 )
102 .await
103 .unwrap_err();
104 assert!(error.to_string().contains("external backends"), "{error}");
105 assert!(!tmp.path().join("should-not-exist").exists());
106 }
107
108 #[cfg(unix)]
109 #[allow(clippy::print_stderr)] // Test receipt distinguishes unavailable enforcement from a real probe.
110 fn native_context(root: &std::path::Path) -> Option<ToolContext> {
111 let mut context = ToolContext::new(root);
112 context.auto_approve = true;
113 // Exercise narrowing from the broadest incoming posture.
114 context.elevated_sandbox_policy = Some(ExecutionSandboxPolicy::DangerFullAccess);
115 context.shell_policy = ShellPolicy::ReadOnly;
116 #[cfg(target_os = "linux")]
117 context.shell_manager.lock().unwrap().set_prefer_bwrap(true);
118 if !context
119 .shell_manager
120 .lock()
121 .unwrap()
122 .configured_sandbox_type()
123 .is_some_and(is_native_readonly_sandbox)
124 {
125 eprintln!("UNRUN: native read_only execution probe; no enforcing sandbox available");
126 return None;
127 }
128 Some(context)
129 }
130
131 #[cfg(unix)]
132 fn python(script: &str) -> String {
133 let binary = [
134 "/usr/bin/python3",
135 "/opt/homebrew/bin/python3",
136 "/usr/local/bin/python3",
137 ]
138 .into_iter()
139 .find(|candidate| std::path::Path::new(candidate).is_file())
140 .expect("Python fixture runtime");
141 format!("{binary} -I -B -c {}", shell_words::quote(script))
142 }
143
144 #[cfg(unix)]
145 #[tokio::test]
146 #[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
147 async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
148 // #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
149 // rooted there vanishes before --chdir reaches it.
150 let tmp = crate::test_support::sandbox_visible_tempdir();
151 let Some(context) = native_context(tmp.path()) else {
152 return;
153 };
154 let database = rusqlite::Connection::open(tmp.path().join("fixture.sqlite")).unwrap();
155 database
156 .execute_batch(
157 "CREATE TABLE fixture(value TEXT); INSERT INTO fixture VALUES ('read-receipt');",
158 )
159 .unwrap();
160 drop(database);
161 std::fs::write(tmp.path().join("peer.txt"), "preserve peer bytes").unwrap();
162 let read = LowercaseBashTool.execute(json!({
163 "command": python("import sqlite3; c=sqlite3.connect('file:fixture.sqlite?mode=ro', uri=True); print(c.execute('SELECT value FROM fixture').fetchone()[0])"),
164 "read_only": true
165 }), &context).await.unwrap();
166 assert!(
167 read.success && read.content.contains("read-receipt"),
168 "{}",
169 read.content
170 );
171 assert_eq!(read.metadata.as_ref().unwrap()["sandboxed"], true);
172 let refused = LowercaseBashTool
173 .execute(
174 json!({
175 "command": python("open('peer.txt', 'w').write('corrupt')"), "read_only": true
176 }),
177 &context,
178 )
179 .await
180 .unwrap_err();
181 assert!(
182 refused.to_string().contains("Operation not permitted")
183 || refused.to_string().contains("Read-only file system")
184 || refused.to_string().contains("Permission denied"),
185 "{refused}"
186 );
187 assert_eq!(
188 std::fs::read_to_string(tmp.path().join("peer.txt")).unwrap(),
189 "preserve peer bytes"
190 );
191 eprintln!(
192 "NATIVE_READONLY_ENFORCED: SQLite read succeeded; write denied and peer bytes preserved"
193 );
194 }
195
196 #[cfg(unix)]
197 #[tokio::test]
198 #[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
199 async fn enforced_readonly_native_python_cannot_reach_a_loopback_listener() {
200 // #6305: the sandbox replaces /tmp with a fresh tmpfs, so a fixture
201 // rooted there vanishes before --chdir reaches it.
202 let tmp = crate::test_support::sandbox_visible_tempdir();
203 let Some(context) = native_context(tmp.path()) else {
204 return;
205 };
206 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
207 listener.set_nonblocking(true).unwrap();
208 let port = listener.local_addr().unwrap().port();
209 let script = format!(
210 "import socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', {port})); print('connected')"
211 );
212 let refused = LowercaseBashTool
213 .execute(
214 json!({"command": python(&script), "read_only": true}),
215 &context,
216 )
217 .await
218 .unwrap_err();
219 // Python includes the submitted source line in its traceback. Only an
220 // actual output line means the script reached the print after connect.
221 assert!(
222 !refused
223 .to_string()
224 .lines()
225 .any(|line| line.trim() == "connected"),
226 "{refused}"
227 );
228 assert_eq!(
229 listener.accept().unwrap_err().kind(),
230 std::io::ErrorKind::WouldBlock
231 );
232 eprintln!(
233 "NATIVE_READONLY_ENFORCED: loopback connection denied; listener received no connection"
234 );
235 }
236
236 lines RUST