Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions products/tasks/backend/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -391,6 +391,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",
Expand All @@ -399,6 +407,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",
)


Expand Down
14 changes: 14 additions & 0 deletions products/tasks/backend/logic/services/agentsh.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import shlex
from pathlib import Path
from urllib.parse import urlparse

from django.conf import settings
Expand All @@ -16,6 +17,19 @@
# Sourced via BASH_ENV on every `bash -c` the agent runs, so git/gh pick up a
# mid-session GitHub credential refresh from its dedicated credential file.
BASH_ENV_SCRIPT = "/tmp/agentsh-bash-env.sh"

# The gh PATH shim (first on PATH; sources the credential script so gh authenticates as the current
# actor in any shell mode). Baked into new base images, but also installed at runtime so resumes
# from pre-shim filesystem snapshots — and any window where the image lags this backend — still
# deliver the token to gh. Read from its single source of truth (the file the Dockerfiles COPY).
GH_GUARD_INSTALL_PATH = "/opt/posthog/bin/gh"
_GH_GUARD_SOURCE_PATH = Path(__file__).resolve().parents[2] / "sandbox" / "images" / "gh-guard.sh"


def read_gh_guard_script() -> bytes:
return _GH_GUARD_SOURCE_PATH.read_bytes()


AGENTSH_AUDIT_DB = "/var/lib/agentsh/events.db"
INFRASTRUCTURE_DOMAINS = [
"*.posthog.com",
Expand Down
12 changes: 12 additions & 0 deletions products/tasks/backend/logic/services/docker_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,15 @@
from .agentsh import (
BASH_ENV_SCRIPT,
ENV_WRAPPER_SCRIPT,
GH_GUARD_INSTALL_PATH,
SESSION_ID_FILE,
build_exec_prefix,
build_setup_script,
generate_bash_env_script,
generate_config_yaml,
generate_env_wrapper,
generate_policy_yaml,
read_gh_guard_script,
)
from .local_skills import ENV_LOCAL_SKILLS_HOST_PATH, LocalSkillsCache
from .sandbox import (
Expand Down Expand Up @@ -874,6 +876,15 @@ def _launch_and_check(self, command: str) -> bool:
return False
return self._wait_for_health_check(max_attempts=20)

def _install_gh_guard(self) -> None:
"""Install the gh PATH shim at runtime so it's present regardless of image age.

New base images bake it in, but a resume from a pre-shim filesystem snapshot (or any window
where the image lags this backend) would otherwise lack it, leaving gh with no token once the
frozen launch-env token is unset."""
self.write_file(GH_GUARD_INSTALL_PATH, read_gh_guard_script())
self.execute(f"chmod +x {shlex.quote(GH_GUARD_INSTALL_PATH)}", timeout_seconds=30)

def start_agent_server(
self,
repository: str | None,
Expand Down Expand Up @@ -920,6 +931,7 @@ def start_agent_server(
# mid-session credential refreshes reach git/gh. Needed for both agentsh
# and non-agentsh runs.
self.write_file(BASH_ENV_SCRIPT, generate_bash_env_script().encode())
self._install_gh_guard()

if allowed_domains is not None:
self._setup_agentsh(WORKING_DIR, allowed_domains)
Expand Down
15 changes: 13 additions & 2 deletions products/tasks/backend/logic/services/modal_sandbox.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
AGENTSH_DAEMON_PORT,
BASH_ENV_SCRIPT,
ENV_WRAPPER_SCRIPT,
GH_GUARD_INSTALL_PATH,
SESSION_ID_FILE,
_hostname_from_url,
build_exec_prefix,
Expand All @@ -64,6 +65,7 @@
generate_config_yaml,
generate_env_wrapper,
generate_policy_yaml,
read_gh_guard_script,
)
from products.tasks.backend.logic.services.local_packages import (
get_local_package_runtime_dependencies,
Expand Down Expand Up @@ -185,6 +187,7 @@ def _resource_create_kwargs(config: SandboxConfig) -> dict[str, object]:
}
LOCAL_MODAL_INSTALL_SKILLS_SCRIPT = Path("products/tasks/backend/sandbox/images/install-skills.sh")
LOCAL_MODAL_GIT_GUARD_SCRIPT = Path("products/tasks/backend/sandbox/images/git-guard.sh")
LOCAL_MODAL_GH_GUARD_SCRIPT = Path("products/tasks/backend/sandbox/images/gh-guard.sh")


_image_ref_cache: TTLCache = TTLCache(maxsize=3, ttl=300)
Expand Down Expand Up @@ -445,11 +448,14 @@ def _prepare_local_modal_build_context(template: SandboxTemplate) -> tuple[str,
destination_dockerfile_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source_dockerfile_path, destination_dockerfile_path)

# Both base and notebook Dockerfiles COPY the git guard, so include it in
# every local build context.
# Both base and notebook Dockerfiles COPY the git and gh guards, so include
# them in every local build context.
destination_git_guard_path = context_dir / LOCAL_MODAL_GIT_GUARD_SCRIPT
destination_git_guard_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(base_dir / LOCAL_MODAL_GIT_GUARD_SCRIPT, destination_git_guard_path)
destination_gh_guard_path = context_dir / LOCAL_MODAL_GH_GUARD_SCRIPT
destination_gh_guard_path.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(base_dir / LOCAL_MODAL_GH_GUARD_SCRIPT, destination_gh_guard_path)

if template == SandboxTemplate.DEFAULT_BASE:
source_install_script_path = base_dir / LOCAL_MODAL_INSTALL_SKILLS_SCRIPT
Expand Down Expand Up @@ -1101,6 +1107,11 @@ def start_agent_server(
repo_path = f"/tmp/workspace/repos/{org}/{repo}"

self.write_file(BASH_ENV_SCRIPT, generate_bash_env_script().encode())
# Install the gh shim at runtime too (see agentsh.GH_GUARD_INSTALL_PATH): a resume from a
# pre-shim filesystem snapshot — or any window where the base image lags this backend —
# would otherwise leave gh with no token once the frozen launch-env token is unset.
self.write_file(GH_GUARD_INSTALL_PATH, read_gh_guard_script())

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Do not source a sandbox-writable credential hook

The runtime-installed gh shim sources /tmp/agentsh-bash-env.sh before it invokes gh. That file is in the shared sandbox filesystem and is writable by the previous Slack actor's agent. An attacker can persist commands in it, then when a more-privileged user sends the next follow-up and their token is written to /tmp/agent-github-env, the shim executes those commands in the privileged command environment. This lets the prior actor use or exfiltrate the new actor's GitHub credential, defeating the per-actor rebind on resumed snapshots.

Prompt To Fix With AI
Do not execute any script from the shared sandbox filesystem as part of credential delivery. On an actor transition, use a freshly provisioned/isolated execution environment for the new actor, or make the credential loader and its token source inaccessible and immutable to prior-agent code. Reinstalling a known script alone is insufficient unless every hook and process state that can run before `gh` is also reset and isolated.

Severity: high | Confidence: 91% | React with 👍 if useful or 👎 if not

self.execute(f"chmod +x {shlex.quote(GH_GUARD_INSTALL_PATH)}", timeout_seconds=30)

if allowed_domains is not None:
self._setup_agentsh(WORKING_DIR, allowed_domains)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -641,6 +641,8 @@ def test_start_agent_server_raises_on_health_check_failure(self, mock_sandbox: A
mock_sandbox.execute = MagicMock(
side_effect=[
ExecutionResult(stdout="", stderr="", exit_code=0, error=None),
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # gh shim write (mv)
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # gh shim chmod
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # --posthogExecPermissionRegex probe
ExecutionResult(stdout="", stderr="", exit_code=1, error=None),
ExecutionResult(stdout="some log output", stderr="", exit_code=0, error=None),
Expand Down Expand Up @@ -696,6 +698,8 @@ def test_start_agent_server_frees_port_before_relaunch(self, mock_sandbox: Any):
mock_sandbox.execute = MagicMock(
side_effect=[
ExecutionResult(stdout="", stderr="", exit_code=0, error=None),
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # gh shim write (mv)
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # gh shim chmod
ExecutionResult(stdout="", stderr="", exit_code=0, error=None), # --posthogExecPermissionRegex probe
ExecutionResult(stdout="", stderr="", exit_code=0, error=None),
ExecutionResult(stdout="ok:1", stderr="", exit_code=0, error=None),
Expand Down
4 changes: 4 additions & 0 deletions products/tasks/backend/sandbox/images/Dockerfile.sandbox-base
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
ENV GH_TELEMETRY=false

# Install system packages (expanded for coding environments)
RUN apt-get update && \

Check warning on line 13 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
apt-get install -y --no-install-recommends \
# Core tools
curl \
Expand Down Expand Up @@ -56,12 +56,12 @@
&& rm -rf /var/lib/apt/lists/*

# Install Node.js 24.x
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \

Check warning on line 59 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`

Check warning on line 59 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
apt-get install -y --no-install-recommends nodejs && \
rm -rf /var/lib/apt/lists/*

# Install additional language package managers
RUN npm install -g yarn pnpm typescript ts-node nodemon

Check warning on line 64 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Pin versions in npm. Instead of `npm install <package>` use `npm install <package>@<version>`

# Install ruff and ty
# uv comes from its pinned, registry-verified official image (same pattern as the
Expand All @@ -85,7 +85,7 @@

# Install agentsh for runtime egress policy enforcement
ARG AGENTSH_TAG=v0.18.3
RUN set -eux; \

Check warning on line 88 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
version="${AGENTSH_TAG#v}"; \
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
Expand All @@ -111,7 +111,7 @@
# developer instructions instead. POSTHOG_RTK=0 (set per run from the task processing
# context) opts a run out of both.
ARG RTK_VERSION=0.43.0
RUN set -eux; \

Check warning on line 114 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check
arch="$(dpkg --print-architecture)"; \
case "$arch" in \
amd64) rtk_asset="rtk-x86_64-unknown-linux-musl.tar.gz"; \
Expand All @@ -136,7 +136,7 @@
# PostHog-side image changes that keep the agent version unchanged.
ARG AGENT_VERSION=latest
ARG COMMIT_HASH
RUN mkdir -p /scripts && \

Check warning on line 139 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-base

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Use WORKDIR to switch to a directory
cd /scripts && \
npm init -y && \
CACHE_BUST=${COMMIT_HASH} npm install "@posthog/agent@${AGENT_VERSION}"
Expand All @@ -158,6 +158,10 @@
# Block `git commit`/`git push` so unsigned commits cannot leave the sandbox
COPY products/tasks/backend/sandbox/images/git-guard.sh /opt/posthog/bin/git
RUN chmod +x /opt/posthog/bin/git
# Inject the per-actor GitHub token into every `gh` call (BASH_ENV only reaches
# non-interactive `bash -c`; the agent's interactive shell needs this shim).
COPY products/tasks/backend/sandbox/images/gh-guard.sh /opt/posthog/bin/gh
RUN chmod +x /opt/posthog/bin/gh
ENV PATH="/opt/posthog/bin:${PATH}"

# This is required for the Claude Code SDK to allow --dangerously-skip-permissions as the root user
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
ENV TZ=UTC

# Install system packages (expanded for notebook environments)
RUN apt-get update && \

Check notice on line 10 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Avoid additional packages by specifying `--no-install-recommends`

Check warning on line 10 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
apt-get install -y \
# Core tools
curl \
Expand Down Expand Up @@ -51,7 +51,7 @@
&& rm -rf /var/lib/apt/lists/*

# Install Node.js 24.x
RUN curl -fsSL https://deb.nodesource.com/setup_24.x | bash - && \

Check warning on line 54 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Set the SHELL option -o pipefail before RUN with a pipe in it. If you are using /bin/sh in an alpine image or if your shell is symlinked to busybox then consider explicitly setting your SHELL to /bin/ash, or disable this check

Check notice on line 54 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Avoid additional packages by specifying `--no-install-recommends`

Check warning on line 54 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Pin versions in apt get install. Instead of `apt-get install <package>` use `apt-get install <package>=<version>`
apt-get install -y nodejs && \
rm -rf /var/lib/apt/lists/*

Expand All @@ -59,7 +59,7 @@
RUN npm install -g yarn pnpm typescript ts-node nodemon

# Install GitHub CLI
RUN curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg && \

Check notice on line 62 in products/tasks/backend/sandbox/images/Dockerfile.sandbox-notebook

View workflow job for this annotation

GitHub Actions / Lint changed Dockerfiles

Avoid additional packages by specifying `--no-install-recommends`
chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg && \
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | tee /etc/apt/sources.list.d/github-cli.list > /dev/null && \
apt-get update && \
Expand Down Expand Up @@ -91,6 +91,10 @@
# Block `git commit`/`git push` so unsigned commits cannot leave the sandbox.
COPY products/tasks/backend/sandbox/images/git-guard.sh /opt/posthog/bin/git
RUN chmod +x /opt/posthog/bin/git
# Inject the per-actor GitHub token into every `gh` call (BASH_ENV only reaches
# non-interactive `bash -c`; the agent's interactive shell needs this shim).
COPY products/tasks/backend/sandbox/images/gh-guard.sh /opt/posthog/bin/gh
RUN chmod +x /opt/posthog/bin/gh
ENV PATH="/opt/posthog/bin:${PATH}"

# This is required for the Claude Code SDK to allow --dangerously-skip-permissions as the root user
Expand Down
33 changes: 33 additions & 0 deletions products/tasks/backend/sandbox/images/gh-guard.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
#!/bin/bash
# gh guard.
#
# Installed first on PATH inside the cloud sandbox image as /opt/posthog/bin/gh.
# The backend delivers the per-actor GitHub token via BASH_ENV, which only
# non-interactive `bash -c` honors — the agent runs its tool commands in an
# interactive shell, so `gh` there would otherwise have no token. This shim
# sources the same credential script the shells do, so `gh` authenticates as the
# current actor regardless of shell mode, and honors logout (an emptied file
# exports nothing, leaving gh unauthenticated rather than falling back to a stale
# token). All arguments pass straight through to the real gh.

native_gh=""
for candidate in /usr/bin/gh /usr/local/bin/gh /bin/gh; do
if [ -x "$candidate" ] && [ "$candidate" != "/opt/posthog/bin/gh" ]; then
native_gh="$candidate"
break
fi
done
if [ -z "$native_gh" ]; then
echo "gh-guard: could not locate the real gh binary" >&2
exit 127
fi

# Re-source the backend-managed credentials fresh on every call (the file is
# rewritten on each refresh / actor transition). The script's sourced branch
# unsets then re-exports GH_TOKEN/GITHUB_TOKEN from the env file.
if [ -f /tmp/agentsh-bash-env.sh ]; then
# shellcheck source=/dev/null
. /tmp/agentsh-bash-env.sh

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟠 Do not source a mutable sandbox script before gh

/tmp/agentsh-bash-env.sh is writable state in the shared, root-run sandbox (and the launch code explicitly treats a snapshot-persisted copy as untrusted). A first Slack actor can replace it with a payload that reads /tmp/agent-github-env; when a later actor is rebound and invokes gh, this new . executes that payload after the later actor's token has been written. This lets an earlier actor exfiltrate a later actor's GitHub credential despite the identity transition.

Prompt To Fix With AI
Do not execute `/tmp/agentsh-bash-env.sh` from the gh shim. Move the minimal credential-file parsing needed by gh into a root-owned, immutable script/image layer, or otherwise have a trusted supervisor pass the current token to gh without sourcing any sandbox-writable script. Ensure a prior actor cannot alter the code that reads a later actor's credential file.

Severity: high | Confidence: 91% | React with 👍 if useful or 👎 if not

fi

exec "$native_gh" "$@"
Loading
Loading