diff --git a/products/tasks/backend/constants.py b/products/tasks/backend/constants.py index be3ea1c1a25e..302d592b8d81 100644 --- a/products/tasks/backend/constants.py +++ b/products/tasks/backend/constants.py @@ -290,6 +290,14 @@ def vm_sandbox_allowed_origins(*, distinct_id: str, organization_id: str) -> set } ) +# Stripped from the agent-server's process environment at launch (env -u). +# Two categories: +# - code-injection vectors a resume snapshot could smuggle in (NODE_*, LD_*, DYLD_*); +# - the GitHub token, so the agent-server holds no frozen copy of the acting user's +# credentials. The token is delivered per command via the live /tmp/agent-env file +# (re-sourced by BASH_ENV, seeded before this unset), so git/gh still authenticate; +# removing the static process-env copy is what lets a mid-session logout or rebind +# actually take effect instead of being resurrected from os.environ. SANDBOX_AGENT_LAUNCH_UNSET_ENV_VARS: tuple[str, ...] = ( "NODE_OPTIONS", "NODE_REPL_EXTERNAL_MODULE", @@ -298,6 +306,8 @@ def vm_sandbox_allowed_origins(*, distinct_id: str, organization_id: str) -> set "LD_AUDIT", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH", + "GITHUB_TOKEN", + "GH_TOKEN", ) diff --git a/products/tasks/backend/temporal/process_task/sandbox_credentials.py b/products/tasks/backend/temporal/process_task/sandbox_credentials.py index 81ce5f20e9cd..15d0ed8044a0 100644 --- a/products/tasks/backend/temporal/process_task/sandbox_credentials.py +++ b/products/tasks/backend/temporal/process_task/sandbox_credentials.py @@ -19,11 +19,13 @@ PrAuthorshipMode, get_github_token, get_pr_authorship_mode, + get_sandbox_github_identity_user, get_sandbox_github_token, get_task_run_credential_user, is_caller_token_run, is_slack_interaction_state, resolve_user_github_integration_for_task, + sandbox_identity_scope, ) if TYPE_CHECKING: @@ -159,7 +161,14 @@ def _live_sandboxes_for_user_integration(user_integration_id: int) -> list[tuple task__github_user_integration_id=user_integration_id, ) .select_related("task") - .only("id", "state", "task__repository", "task__github_user_integration_id", "task__origin_product") + .only( + "id", + "state", + "task__repository", + "task__github_user_integration_id", + "task__origin_product", + "task__created_by_id", + ) ) for run in runs: sandbox_id = (run.state or {}).get("sandbox_id") @@ -172,6 +181,13 @@ def _live_sandboxes_for_user_integration(user_integration_id: int) -> list[tuple continue if is_caller_token_run(str(run.id), run.state): continue + # A per-message actor transition may have rebound (or logged out) this sandbox's GitHub + # identity to someone other than the run owner. This loop carries the owner's token, so + # re-applying it would undo that transition and resurrect the owner's identity for the + # current actor. Skip when the sandbox is bound to a different actor. + bound_actor = get_sandbox_github_identity_user(sandbox_identity_scope(str(run.id), run.state)) + if bound_actor is not None and bound_actor != run.task.created_by_id: + continue rows.append((str(run.id), sandbox_id, run.task.repository)) return rows diff --git a/products/tasks/backend/temporal/process_task/tests/test_sandbox_credentials.py b/products/tasks/backend/temporal/process_task/tests/test_sandbox_credentials.py index 014fd645ff43..4457cb9b8bac 100644 --- a/products/tasks/backend/temporal/process_task/tests/test_sandbox_credentials.py +++ b/products/tasks/backend/temporal/process_task/tests/test_sandbox_credentials.py @@ -462,3 +462,43 @@ def _task(repo): result = _live_sandboxes_for_user_integration(integration.id) assert result == [(str(live_run.id), "sb-live", "org/live")] + + @pytest.mark.parametrize("marker,included", [("none", True), ("owner", True), ("other", False)]) + def test_actor_transition_gates_owner_token_propagation(self, marker, included): + from posthog.models import Organization, Team + from posthog.models.user import User + from posthog.models.user_integration import UserIntegration + + from products.tasks.backend.models import Task, TaskRun + from products.tasks.backend.temporal.process_task.sandbox_credentials import ( + _live_sandboxes_for_user_integration, + ) + from products.tasks.backend.temporal.process_task.utils import mark_sandbox_github_identity + + org = Organization.objects.create(name="o") + team = Team.objects.create(organization=org, name="t") + owner = User.objects.create(email="owner@test.com") + other = User.objects.create(email="other@test.com") + integration = UserIntegration.objects.create( + user=owner, kind=UserIntegration.IntegrationKind.GITHUB, integration_id="i1", config={}, sensitive_config={} + ) + task = Task.objects.create( + team=team, created_by=owner, repository="org/repo", github_user_integration=integration + ) + run = TaskRun.objects.create( + task=task, + team=team, + status=TaskRun.Status.IN_PROGRESS, + state={"sandbox_id": "sb-x", "pr_authorship_mode": "user"}, + ) + # An unset marker (no transition yet) and one bound to the owner both propagate; a marker + # bound to a different per-message actor means the sandbox was logged out / rebound, so the + # owner's rotating token must not overwrite it. + if marker == "owner": + mark_sandbox_github_identity("sb-x", owner.id) + elif marker == "other": + mark_sandbox_github_identity("sb-x", other.id) + + result = _live_sandboxes_for_user_integration(integration.id) + + assert (result == [(str(run.id), "sb-x", "org/repo")]) is included