diff --git a/tests/test_security_read_env_bypass.py b/tests/test_security_read_env_bypass.py new file mode 100644 index 00000000..b8e2d0b8 --- /dev/null +++ b/tests/test_security_read_env_bypass.py @@ -0,0 +1,103 @@ +# encoding:utf-8 +""" +Unit tests for Read tool credential-bypass protection (issue #2913). + +Verifies that the read tool blocks not just the literal ~/.cow/.env file but +also its process-environment aliases (/proc//environ) and symlinks that +resolve to it, WITHOUT re-broadening the scope narrowed by #2863. +""" +import os +import sys +import tempfile +import unittest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) + +from agent.tools.read.read import Read +from common.utils import expand_path + +_DENIED = "Access denied" + + +class TestReadCredentialBypass(unittest.TestCase): + """_is_credential_path must block credential files and their aliases.""" + + def setUp(self): + self.read = Read() + + # ---- happy path: ordinary files are NOT blocked ----------------------- + + def test_normal_file_not_blocked(self): + """A regular temp file must not be treated as a credential path.""" + with tempfile.NamedTemporaryFile(suffix=".txt", delete=False) as tf: + tf.write(b"hello world") + tmp_path = tf.name + try: + self.assertFalse(self.read._is_credential_path(tmp_path)) + result = self.read.execute({"path": tmp_path}) + self.assertEqual(result.status, "success") + finally: + os.unlink(tmp_path) + + def test_non_environ_proc_file_not_blocked(self): + """Non-environ /proc files must not be over-blocked (no #2863 regression).""" + self.assertFalse(self.read._is_credential_path("/proc/self/status")) + self.assertFalse(self.read._is_credential_path("/proc/1/cmdline")) + + # ---- control: the literal credential file stays blocked -------------- + + def test_direct_env_file_blocked(self): + """Direct ~/.cow/.env access must remain blocked (regression control).""" + env_path = expand_path("~/.cow/.env") + self.assertTrue(self.read._is_credential_path(env_path)) + result = self.read.execute({"path": env_path}) + self.assertEqual(result.status, "error") + self.assertIn(_DENIED, str(result.result)) + + # ---- the vulnerability: environ aliases are blocked ------------------ + + def test_proc_self_environ_blocked(self): + """/proc/self/environ must be blocked (the #2913 bypass).""" + self.assertTrue(self.read._is_credential_path("/proc/self/environ")) + result = self.read.execute({"path": "/proc/self/environ"}) + self.assertEqual(result.status, "error") + self.assertIn(_DENIED, str(result.result)) + + def test_proc_pid_environ_blocked(self): + """/proc//environ must be blocked for any pid.""" + self.assertTrue(self.read._is_credential_path("/proc/1/environ")) + self.assertTrue(self.read._is_credential_path(f"/proc/{os.getpid()}/environ")) + + def test_proc_thread_self_environ_blocked(self): + """/proc/thread-self/environ must be blocked.""" + self.assertTrue(self.read._is_credential_path("/proc/thread-self/environ")) + + # ---- symlink escape -------------------------------------------------- + + @unittest.skipUnless(hasattr(os, "symlink"), "symlink not supported") + def test_symlink_to_env_file_blocked(self): + """A symlink resolving to ~/.cow/.env must be blocked.""" + env_path = expand_path("~/.cow/.env") + os.makedirs(os.path.dirname(env_path), exist_ok=True) + created_env = False + if not os.path.exists(env_path): + with open(env_path, "w", encoding="utf-8") as f: + f.write("SECRET=canary\n") + created_env = True + link_dir = tempfile.mkdtemp() + link_path = os.path.join(link_dir, "innocent.txt") + try: + os.symlink(env_path, link_path) + except (OSError, NotImplementedError): + self.skipTest("cannot create symlink in this environment") + try: + self.assertTrue(self.read._is_credential_path(link_path)) + finally: + os.unlink(link_path) + os.rmdir(link_dir) + if created_env: + os.unlink(env_path) + + +if __name__ == "__main__": + unittest.main()