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
4 changes: 4 additions & 0 deletions codeframe/core/agent.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@

from codeframe.adapters.llm import LLMProvider, Purpose
from codeframe.core import blockers, events
from codeframe.core.agent_env import build_agent_env
from codeframe.core.path_safety import is_path_safe
from codeframe.core.context import ContextLoader, TaskContext
from codeframe.core.events import EventType
Expand Down Expand Up @@ -895,6 +896,7 @@ def _try_auto_fix(self, gate_result: GateResult) -> bool:
capture_output=True,
text=True,
timeout=30,
env=build_agent_env(self.workspace.repo_path),
)

if result.returncode == 0:
Expand Down Expand Up @@ -1320,6 +1322,8 @@ def _run_command() -> subprocess.CompletedProcess:
capture_output=True,
text=True,
timeout=120,
# LLM-authored argv (#907).
env=build_agent_env(self.workspace.repo_path),
)

# Global scope commands should go through Coordinator
Expand Down
29 changes: 19 additions & 10 deletions codeframe/core/agent_env.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@

from __future__ import annotations

import logging
import os
from pathlib import Path

Expand Down Expand Up @@ -49,32 +50,40 @@
#: still let an XDG path resolve back to the operator's real home.
_XDG_VARS = ("XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME")

logger = logging.getLogger(__name__)

def build_agent_env(workspace_path: Path) -> dict[str, str]:

def build_agent_env(workspace_path: Path | str) -> dict[str, str]:
"""The environment for a subprocess an agent (or repo content) can steer.

Args:
workspace_path: The workspace the command runs in. The sandboxed ``HOME``
lives under its ``.codeframe/`` state dir, so it is per-workspace and
inspectable.
"""
workspace_path = Path(workspace_path)
env = {k: os.environ[k] for k in SAFE_ENV_VARS if k in os.environ}

# A real directory rather than a nonexistent path, so tools that write
# dotfiles (npm, pip, cargo, git) still work.
sandbox_home = workspace_path / ".codeframe" / "agent-home"
try:
sandbox_home.mkdir(parents=True, exist_ok=True)
env["HOME"] = str(sandbox_home)
for xdg in _XDG_VARS:
env[xdg] = str(sandbox_home / xdg.lower())
except OSError:
# Fail closed: drop the pointers entirely rather than fall back to the
# operator's. XDG_CONFIG_HOME is on the allowlist, so leaving it set
# would still reach ~/.config (gh/hosts.yml and friends).
env.pop("HOME", None)
for xdg in _XDG_VARS:
env.pop(xdg, None)
# Point at it anyway. Deleting HOME does NOT fail closed: with the
# variable *unset*, `expanduser("~")` and everything built on it fall
# back to getpwuid() and resolve the operator's real home — so the
# child would quietly regain ~/.codeframe, ~/.npmrc, ~/.config/gh.
# A set-but-missing directory keeps the pointer away from the operator
# and makes tools that truly need to write there fail visibly.
logger.warning(
"Could not create the agent sandbox home %s; subprocesses will run "
"with a non-existent HOME rather than the operator's.", sandbox_home
)

env["HOME"] = str(sandbox_home)
for xdg in _XDG_VARS:
env[xdg] = str(sandbox_home / xdg.lower())

for venv_dir in (".venv", "venv"):
venv_bin = workspace_path / venv_dir / "bin"
Expand Down
14 changes: 14 additions & 0 deletions codeframe/core/gates.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pathlib import Path
from typing import Any, Callable, Optional

from codeframe.core.agent_env import build_agent_env
from codeframe.core.workspace import Workspace
from codeframe.core import events

Expand Down Expand Up @@ -216,6 +217,7 @@ def _ensure_dependencies_installed(
result = subprocess.run(
["uv", "pip", "install", "-r", str(requirements_txt)],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300, # 5 minutes
Expand All @@ -231,6 +233,7 @@ def _ensure_dependencies_installed(
result = subprocess.run(
["pip", "install", "-r", str(requirements_txt)],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300,
Expand Down Expand Up @@ -261,6 +264,7 @@ def _ensure_dependencies_installed(
result = subprocess.run(
["npm", "install"],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300, # 5 minutes
Expand Down Expand Up @@ -507,6 +511,7 @@ def _run_pytest(
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300, # 5 minute timeout
Expand Down Expand Up @@ -607,6 +612,7 @@ def _run_ruff(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=60,
Expand Down Expand Up @@ -663,6 +669,7 @@ def _run_mypy(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
["mypy", "."],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=120,
Expand Down Expand Up @@ -713,6 +720,7 @@ def _run_npm_test(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
["npm", "test"],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300,
Expand Down Expand Up @@ -763,6 +771,7 @@ def _run_npm_lint(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
["npm", "run", "lint"],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=120,
Expand Down Expand Up @@ -840,6 +849,7 @@ def _run_python_build(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=60, # 1 minute for import check
Expand Down Expand Up @@ -918,6 +928,7 @@ def _run_npm_build(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
["npm", "run", "build"],
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=300, # 5 minutes for builds
Expand Down Expand Up @@ -999,6 +1010,7 @@ def _run_tsc(repo_path: Path, verbose: bool = False) -> GateCheck:
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=120, # 2 minutes, same as mypy
Expand Down Expand Up @@ -1145,6 +1157,7 @@ def run_lint_on_file(
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=timeout,
Expand Down Expand Up @@ -1244,6 +1257,7 @@ def run_autofix_on_file(
result = subprocess.run(
cmd,
cwd=repo_path,
env=build_agent_env(repo_path),
capture_output=True,
text=True,
timeout=timeout,
Expand Down
11 changes: 8 additions & 3 deletions codeframe/core/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@
from __future__ import annotations

import logging
import os
import subprocess
import time
from dataclasses import dataclass
Expand All @@ -24,6 +23,8 @@

from jinja2 import Template

from codeframe.core.agent_env import build_agent_env

if TYPE_CHECKING:
from codeframe.core.config import EnvironmentConfig

Expand Down Expand Up @@ -137,8 +138,12 @@ def run_hook(
capture_output=True,
text=True,
timeout=timeout,
# Context values arrive here, not spliced into the command text.
env={**os.environ, **hook_context_env(ctx)},
# A hook is a shell command from repo config, so it gets the same
# credential-free environment as every other agent-steerable
# subprocess (#907) — the trust decision (#905) says the command may
# *run*, not that it may read the operator's API keys. Context
# values arrive here too, never spliced into the command text.
env={**build_agent_env(workspace_path), **hook_context_env(ctx)},
)
duration_ms = int((time.monotonic() - start) * 1000)
return HookResult(
Expand Down
7 changes: 7 additions & 0 deletions codeframe/core/quick_fixes.py
Original file line number Diff line number Diff line change
Expand Up @@ -505,12 +505,19 @@ def apply_quick_fix(
return True, f"Would run: {fix.command}"

import subprocess

from codeframe.core.agent_env import build_agent_env

result = subprocess.run(
fix.command.split(),
cwd=repo_path,
capture_output=True,
text=True,
timeout=120,
# A package install runs the package's own postinstall scripts
# (#907). Same allowlisted, credential-free env as every other
# agent-triggered subprocess.
env=build_agent_env(repo_path),
)

if result.returncode == 0:
Expand Down
Loading
Loading