Files
Gitea-Tools/tests/test_issue_704_prevent_env_workspace_bindings.py
T

167 lines
7.0 KiB
Python

"""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()