From ef34d938bf419b4fc0f9a0c9efbd5c7f762a1a01 Mon Sep 17 00:00:00 2001 From: Jason Walker <913443@dadeschools.net> Date: Sat, 25 Jul 2026 19:12:54 -0400 Subject: [PATCH] fix(auth): prevent repository .env files from injecting workspace bindings (Closes #704) --- gitea_auth.py | 66 ++++++- ...ssue_704_prevent_env_workspace_bindings.py | 166 ++++++++++++++++++ 2 files changed, 228 insertions(+), 4 deletions(-) create mode 100644 tests/test_issue_704_prevent_env_workspace_bindings.py diff --git a/gitea_auth.py b/gitea_auth.py index ef7caac..9c4ce28 100644 --- a/gitea_auth.py +++ b/gitea_auth.py @@ -22,8 +22,62 @@ import gitea_config PROJECT_ROOT = os.path.dirname(os.path.abspath(__file__)) -# Load standard .env if present -load_dotenv(os.path.join(PROJECT_ROOT, ".env")) +# Reserved runtime-control / workspace-binding environment variables (#704). +# Repository .env files MUST NOT populate or override any of these keys. +RESERVED_WORKTREE_ENV_KEYS: frozenset[str] = frozenset({ + "GITEA_ACTIVE_WORKTREE", + "GITEA_AUTHOR_WORKTREE", + "GITEA_REVIEWER_WORKTREE", + "GITEA_MERGER_WORKTREE", + "GITEA_RECONCILER_WORKTREE", +}) + + +def is_reserved_worktree_env_key(key: str | None) -> bool: + """Return True if *key* is a reserved runtime workspace binding variable (#704).""" + if not key: + return False + k = str(key).upper().strip() + return k in RESERVED_WORKTREE_ENV_KEYS or (k.startswith("GITEA_") and k.endswith("_WORKTREE")) + + +def load_env_file_sanitized( + env_path: str, + *, + target_env: dict | os._Environ | None = None, +) -> list[str]: + """Load a .env file without populating or overriding reserved workspace keys (#704). + + Pre-existing process environment values retain their precedence. Reserved + runtime-control keys found in repository files are ignored (without logging + their values). Returns a list of sanitized rejection reasons. + """ + if target_env is None: + target_env = os.environ + if not os.path.exists(env_path) or os.path.isdir(env_path): + return [] + + rejection_reasons: list[str] = [] + try: + file_vals = dotenv_values(env_path) + for key, val in file_vals.items(): + if not key or val is None: + continue + if is_reserved_worktree_env_key(key): + filename = os.path.basename(env_path) + rejection_reasons.append( + f"Ignored reserved runtime workspace key '{key}' from repository {filename}" + ) + continue + if key not in target_env: + target_env[key] = val + except Exception: + pass + return rejection_reasons + + +# Load standard .env if present (sanitized to prevent repo workspace binding contamination #704) +load_env_file_sanitized(os.path.join(PROJECT_ROOT, ".env")) # Dictionary to store configurations parsed dynamically from .env.* files DYNAMIC_CONFIGS = {} @@ -37,9 +91,13 @@ for env_path in glob.glob(os.path.join(PROJECT_ROOT, ".env*")): continue try: config_vals = dotenv_values(env_path) - site = config_vals.get("GITEA_SITE") or config_vals.get("GITEA_HOST") + # Filter out reserved workspace keys from dynamic configs (#704) + sanitized_config = { + k: v for k, v in config_vals.items() if not is_reserved_worktree_env_key(k) + } + site = sanitized_config.get("GITEA_SITE") or sanitized_config.get("GITEA_HOST") if site: - DYNAMIC_CONFIGS[site.lower().strip()] = config_vals + DYNAMIC_CONFIGS[site.lower().strip()] = sanitized_config except Exception: pass diff --git a/tests/test_issue_704_prevent_env_workspace_bindings.py b/tests/test_issue_704_prevent_env_workspace_bindings.py new file mode 100644 index 0000000..7b37eaa --- /dev/null +++ b/tests/test_issue_704_prevent_env_workspace_bindings.py @@ -0,0 +1,166 @@ +"""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()