返回 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!(!exec_shell_input_agent_readonly(&input), "{input}");
41 let error = BashTool::new("Bash")
42 .execute(input, &context)
43 .await
44 .unwrap_err();
45 assert!(error.to_string().contains("incompatible"), "{error}");
46 assert!(!tmp.path().join("should-not-exist").exists());
47 }
48 }
49
50 #[test]
51 fn enforced_readonly_refuses_an_unenforced_or_writable_prepared_environment() {
52 let tmp = tempdir().unwrap();
53 let mut environment = ExecEnv {
54 command: vec!["must-not-run".into()],
55 cwd: tmp.path().into(),
56 env: HashMap::new(),
57 timeout: Duration::from_secs(1),
58 sandbox_type: SandboxType::None,
59 policy: ExecutionSandboxPolicy::ReadOnly,
60 };
61 assert!(require_native_readonly_execution(&environment).is_err());
62 #[cfg(target_os = "macos")]
63 {
64 environment.sandbox_type = SandboxType::MacosSeatbelt;
65 }
66 #[cfg(all(target_os = "linux", not(target_env = "ohos")))]
67 {
68 environment.sandbox_type = SandboxType::LinuxBubblewrap;
69 }
70 environment.policy = ExecutionSandboxPolicy::DangerFullAccess;
71 assert!(require_native_readonly_execution(&environment).is_err());
72 }
73
74 struct RefusingExternalBackend;
75 #[async_trait]
76 impl crate::sandbox::backend::SandboxBackend for RefusingExternalBackend {
77 fn kind(&self) -> crate::sandbox::backend::SandboxKind {
78 crate::sandbox::backend::SandboxKind::Unsupported
79 }
80 async fn exec(
81 &self,
82 _command: &str,
83 _env: &HashMap<String, String>,
84 ) -> Result<crate::sandbox::backend::SandboxOutput> {
85 panic!("enforced read-only must never reach an unattested external executor")
86 }
87 }
88
89 #[tokio::test]
90 async fn enforced_readonly_refuses_external_backend_without_dispatch() {
91 let tmp = tempdir().unwrap();
92 let mut context = ToolContext::new(tmp.path());
93 context.sandbox_backend = Some(std::sync::Arc::new(RefusingExternalBackend));
94 let error = LowercaseBashTool
95 .execute(
96 json!({"command": "touch should-not-exist", "read_only": true}),
97 &context,
98 )
99 .await
100 .unwrap_err();
101 assert!(error.to_string().contains("external backends"), "{error}");
102 assert!(!tmp.path().join("should-not-exist").exists());
103 }
104
105 #[cfg(unix)]
106 #[allow(clippy::print_stderr)] // Test receipt distinguishes unavailable enforcement from a real probe.
107 fn native_context(root: &std::path::Path) -> Option<ToolContext> {
108 let mut context = ToolContext::new(root);
109 context.auto_approve = true;
110 // Exercise narrowing from the broadest incoming posture.
111 context.elevated_sandbox_policy = Some(ExecutionSandboxPolicy::DangerFullAccess);
112 context.shell_policy = ShellPolicy::ReadOnly;
113 #[cfg(target_os = "linux")]
114 context.shell_manager.lock().unwrap().set_prefer_bwrap(true);
115 if !context
116 .shell_manager
117 .lock()
118 .unwrap()
119 .configured_sandbox_type()
120 .is_some_and(is_native_readonly_sandbox)
121 {
122 eprintln!("UNRUN: native read_only execution probe; no enforcing sandbox available");
123 return None;
124 }
125 Some(context)
126 }
127
128 #[cfg(unix)]
129 fn python(script: &str) -> String {
130 let binary = [
131 "/usr/bin/python3",
132 "/opt/homebrew/bin/python3",
133 "/usr/local/bin/python3",
134 ]
135 .into_iter()
136 .find(|candidate| std::path::Path::new(candidate).is_file())
137 .expect("Python fixture runtime");
138 format!("{binary} -I -B -c {}", shell_words::quote(script))
139 }
140
141 #[cfg(unix)]
142 #[tokio::test]
143 #[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
144 async fn enforced_readonly_native_python_reads_sqlite_and_cannot_write() {
145 let tmp = tempdir().unwrap();
146 let Some(context) = native_context(tmp.path()) else {
147 return;
148 };
149 let database = rusqlite::Connection::open(tmp.path().join("fixture.sqlite")).unwrap();
150 database
151 .execute_batch(
152 "CREATE TABLE fixture(value TEXT); INSERT INTO fixture VALUES ('read-receipt');",
153 )
154 .unwrap();
155 drop(database);
156 std::fs::write(tmp.path().join("peer.txt"), "preserve peer bytes").unwrap();
157 let read = LowercaseBashTool.execute(json!({
158 "command": python("import sqlite3; c=sqlite3.connect('file:fixture.sqlite?mode=ro', uri=True); print(c.execute('SELECT value FROM fixture').fetchone()[0])"),
159 "read_only": true
160 }), &context).await.unwrap();
161 assert!(
162 read.success && read.content.contains("read-receipt"),
163 "{}",
164 read.content
165 );
166 assert_eq!(read.metadata.as_ref().unwrap()["sandboxed"], true);
167 let refused = LowercaseBashTool
168 .execute(
169 json!({
170 "command": python("open('peer.txt', 'w').write('corrupt')"), "read_only": true
171 }),
172 &context,
173 )
174 .await
175 .unwrap_err();
176 assert!(
177 refused.to_string().contains("Operation not permitted")
178 || refused.to_string().contains("Read-only file system")
179 || refused.to_string().contains("Permission denied"),
180 "{refused}"
181 );
182 assert_eq!(
183 std::fs::read_to_string(tmp.path().join("peer.txt")).unwrap(),
184 "preserve peer bytes"
185 );
186 eprintln!(
187 "NATIVE_READONLY_ENFORCED: SQLite read succeeded; write denied and peer bytes preserved"
188 );
189 }
190
191 #[cfg(unix)]
192 #[tokio::test]
193 #[allow(clippy::print_stderr)] // Native enforcement receipt, outside the TUI runtime.
194 async fn enforced_readonly_native_python_cannot_reach_a_loopback_listener() {
195 let tmp = tempdir().unwrap();
196 let Some(context) = native_context(tmp.path()) else {
197 return;
198 };
199 let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
200 listener.set_nonblocking(true).unwrap();
201 let port = listener.local_addr().unwrap().port();
202 let script = format!(
203 "import socket; s=socket.socket(); s.settimeout(1); s.connect(('127.0.0.1', {port})); print('connected')"
204 );
205 let refused = LowercaseBashTool
206 .execute(
207 json!({"command": python(&script), "read_only": true}),
208 &context,
209 )
210 .await
211 .unwrap_err();
212 assert!(!refused.to_string().contains("connected"), "{refused}");
213 assert_eq!(
214 listener.accept().unwrap_err().kind(),
215 std::io::ErrorKind::WouldBlock
216 );
217 eprintln!(
218 "NATIVE_READONLY_ENFORCED: loopback connection denied; listener received no connection"
219 );
220 }
221
221 lines RUST