"""Tests for Issue #704: Preventing repository .env files from injecting workspace bindings. Acceptance Criteria (#704): 1. Repository .env loading cannot populate or override GITEA_ACTIVE_WORKTREE or any role-specific GITEA_*_WORKTREE runtime-binding variable. 2. Runtime workspace bindings are accepted only from sanctioned managed-launch/session mechanisms. 3. Pre-existing sanctioned process environment values retain their intended precedence. 4. Importing gitea_auth or related modules does not mutate workspace-binding state from repository files. 5. Reserved runtime-control keys found in .env are ignored or rejected with a sanitized actionable reason; their values are never logged. 6. The protection applies consistently to author, reviewer, merger, and reconciler namespaces. 7. Comprehensive test coverage for stale worktree, missing worktree, task-specific, role-specific, launcher binding, repeated imports, namespace isolation, precedence, and absence of secret leakage. 8. Dirty-state and workspace-preflight gates cannot be bypassed by an injected missing-path binding. 9. No environment, dotenv, offline-import, or caller-controlled path can forge native transport or mutation provenance. 10. Cross-linked with #702, PR #703, #510. 11. Required immediate follow-up to Issue #702 / PR #703. """ from __future__ import annotations import os import sys import tempfile import importlib import unittest from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import gitea_auth from gitea_auth import is_reserved_worktree_env_key, load_env_file_sanitized class TestIssue704PreventEnvWorkspaceBindings(unittest.TestCase): """Test suite verifying .env workspace-binding injection prevention (#704).""" def setUp(self): self.tmpdir = tempfile.TemporaryDirectory() self.addCleanup(self.tmpdir.cleanup) self.env_dir = Path(self.tmpdir.name) def test_is_reserved_worktree_env_key(self): """Verify key classification for all role namespaces (#704 AC6).""" reserved_keys = [ "GITEA_ACTIVE_WORKTREE", "GITEA_AUTHOR_WORKTREE", "GITEA_REVIEWER_WORKTREE", "GITEA_MERGER_WORKTREE", "GITEA_RECONCILER_WORKTREE", "gitea_active_worktree", "GITEA_CUSTOM_ROLE_WORKTREE", ] for key in reserved_keys: self.assertTrue( is_reserved_worktree_env_key(key), f"Expected {key} to be recognized as a reserved worktree key", ) unreserved_keys = [ "GITEA_USER", "GITEA_PASS", "GITEA_TOKEN", "GITEA_HOST", "PATH", ] for key in unreserved_keys: self.assertFalse( is_reserved_worktree_env_key(key), f"Expected {key} to NOT be recognized as a reserved worktree key", ) def test_load_env_file_sanitized_ignores_reserved_keys(self): """Verify .env loading ignores GITEA_ACTIVE_WORKTREE and role-specific keys (#704 AC1).""" env_file = self.env_dir / ".env" stale_path = "/tmp/stale-worktree-path-1234" env_file.write_text( f"GITEA_USER=testuser\n" f"GITEA_ACTIVE_WORKTREE={stale_path}\n" f"GITEA_AUTHOR_WORKTREE={stale_path}\n" f"GITEA_REVIEWER_WORKTREE={stale_path}\n" f"GITEA_MERGER_WORKTREE={stale_path}\n" f"GITEA_RECONCILER_WORKTREE={stale_path}\n" ) test_env = {} reasons = load_env_file_sanitized(str(env_file), target_env=test_env) # Unreserved key loaded self.assertEqual(test_env.get("GITEA_USER"), "testuser") # Reserved keys ignored self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env) self.assertNotIn("GITEA_AUTHOR_WORKTREE", test_env) self.assertNotIn("GITEA_REVIEWER_WORKTREE", test_env) self.assertNotIn("GITEA_MERGER_WORKTREE", test_env) self.assertNotIn("GITEA_RECONCILER_WORKTREE", test_env) # Rejection reasons populated without leaking the secret value (#704 AC5) self.assertTrue(len(reasons) >= 5) for r in reasons: self.assertNotIn(stale_path, r, "Secret/path value must not leak into rejection reason") def test_preexisting_sanctioned_launcher_env_retained(self): """Sanctioned launcher values in process env are retained (#704 AC2, AC3).""" sanctioned_path = "/tmp/sanctioned-launcher-worktree" test_env = {"GITEA_ACTIVE_WORKTREE": sanctioned_path} env_file = self.env_dir / ".env" env_file.write_text("GITEA_ACTIVE_WORKTREE=/tmp/injected-repo-worktree\n") load_env_file_sanitized(str(env_file), target_env=test_env) # Pre-existing value retained, not overwritten by .env self.assertEqual(test_env.get("GITEA_ACTIVE_WORKTREE"), sanctioned_path) def test_stale_or_missing_worktree_in_env_ignored(self): """Stale or non-existent worktree path in .env file is ignored (#704 AC7).""" nonexistent_path = "/nonexistent/branches/stale-issue-999" env_file = self.env_dir / ".env" env_file.write_text(f"GITEA_ACTIVE_WORKTREE={nonexistent_path}\n") test_env = {} load_env_file_sanitized(str(env_file), target_env=test_env) self.assertNotIn("GITEA_ACTIVE_WORKTREE", test_env) def test_repeated_module_import_does_not_mutate_workspace_env(self): """Repeated imports of gitea_auth leave os.environ un-contaminated (#704 AC4, AC7).""" # Ensure no active worktree env exists initially original_val = os.environ.pop("GITEA_ACTIVE_WORKTREE", None) try: importlib.reload(gitea_auth) self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ) importlib.reload(gitea_auth) self.assertNotIn("GITEA_ACTIVE_WORKTREE", os.environ) finally: if original_val is not None: os.environ["GITEA_ACTIVE_WORKTREE"] = original_val def test_namespace_isolation_all_roles_protected(self): """Verify protection across author, reviewer, merger, reconciler (#704 AC6).""" env_file = self.env_dir / ".env" env_file.write_text( "GITEA_AUTHOR_WORKTREE=/bad/author\n" "GITEA_REVIEWER_WORKTREE=/bad/reviewer\n" "GITEA_MERGER_WORKTREE=/bad/merger\n" "GITEA_RECONCILER_WORKTREE=/bad/reconciler\n" ) test_env = {} load_env_file_sanitized(str(env_file), target_env=test_env) self.assertEqual(test_env, {}) def test_absence_of_secret_leakage(self): """Rejection reasons contain key names but never secret path values (#704 AC5).""" sensitive_path = "/Users/secret/path/private_repo" env_file = self.env_dir / ".env" env_file.write_text(f"GITEA_ACTIVE_WORKTREE={sensitive_path}\n") test_env = {} reasons = load_env_file_sanitized(str(env_file), target_env=test_env) for reason in reasons: self.assertNotIn(sensitive_path, reason) if __name__ == "__main__": unittest.main()