Skip to content
Merged
16 changes: 16 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,22 @@ CODEFRAME_ALLOW_UNRESTRICTED_WORKSPACES=1 # Escape hatch for the above: start w
# never set on an exposed server. Mirrors
# CODEFRAME_ALLOW_INSECURE_SECRET (#643).

# Repo-committed hook trust (#905) — default OFF (refuse)
CODEFRAME_ALLOW_HOOKS=1 # Run a workspace's configured lifecycle
# hooks without a recorded trust decision.
# Hooks come from .codeframe/config.yaml,
# a file a cloned repo can commit, and
# `cf init` fires after_init immediately —
# so by default a hook runs only after
# `cf hooks trust` records approval in
# ~/.codeframe/trusted_hooks.json (outside
# the repo tree, keyed to the exact
# commands, so editing a hook re-asks).
# `cf init --allow-hooks` sets this for
# one run. CODEFRAME.md can no longer
# supply hooks at all: it is found by
# walking UP from the workspace.

# Bootstrap registration gate (#897)
CODEFRAME_BOOTSTRAP_TOKEN=<secret> # Out-of-band secret gating the
# unauthenticated POST /auth/register
Expand Down
9 changes: 9 additions & 0 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"""

import json
import os
import sys
from pathlib import Path
from typing import Optional
Expand Down Expand Up @@ -120,6 +121,11 @@ def init(
"--force",
help="Overwrite an existing CODEFRAME.md (with --generate-config)",
),
allow_hooks: bool = typer.Option(
False,
"--allow-hooks",
help="Run this repository's configured hooks without prompting (#905)",
),
) -> None:
"""Initialize a CodeFRAME workspace for a repository.

Expand Down Expand Up @@ -193,7 +199,10 @@ def init(

# Execute after_init hook (non-blocking, only on fresh init)
from codeframe.core.config import load_environment_config
from codeframe.core.hook_trust import ALLOW_HOOKS_ENV
from codeframe.core.hooks import HookContext, execute_hook
if allow_hooks:
os.environ[ALLOW_HOOKS_ENV] = "1"
env_config = load_environment_config(repo_path)
if env_config and not already_existed:
hook_ctx = HookContext(
Expand Down
66 changes: 66 additions & 0 deletions codeframe/cli/hooks_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
codeframe hooks run <hook_name> # Manually trigger a hook
codeframe hooks set <name> <cmd> # Set a hook command
codeframe hooks clear <name> # Remove a hook
codeframe hooks trust # Approve repo-supplied hooks (#905)
"""

from pathlib import Path
Expand Down Expand Up @@ -72,6 +73,16 @@ def hooks_show(

console.print(table)

from codeframe.core.hook_trust import describe_hooks, is_trusted
if describe_hooks(config.hooks):
if is_trusted(path, config.hooks):
console.print("[green]Trusted[/green] — these hooks may run.")
else:
console.print(
"[yellow]Not trusted[/yellow] — these hooks will NOT run. "
"Approve with 'codeframe hooks trust'."
)


@hooks_app.command("run")
def hooks_run(
Expand Down Expand Up @@ -166,6 +177,7 @@ def hooks_set(

setattr(config.hooks, hook_name, command)
save_environment_config(path, config)
_record_trust(path, config)

console.print(f"[green]Hook '{hook_name}' set to:[/green] {command}")

Expand Down Expand Up @@ -204,5 +216,59 @@ def hooks_clear(

setattr(config.hooks, hook_name, None)
save_environment_config(path, config)
_record_trust(path, config)

console.print(f"[green]Hook '{hook_name}' cleared.[/green]")


def _record_trust(path: Path, config: "object") -> None:
"""Approve the hooks the operator just edited via the CLI.

Trust is keyed on the exact commands (#905), so any edit would otherwise
revoke it — including the operator's own.
"""
from codeframe.core.hook_trust import record_trust

record_trust(path, config.hooks) # type: ignore[attr-defined]


@hooks_app.command("trust")
def hooks_trust(
workspace_path: Optional[Path] = typer.Option(
None, "--workspace", "-w",
help="Workspace path (defaults to current directory)",
),
yes: bool = typer.Option(False, "--yes", "-y", help="Skip the confirmation prompt"),
) -> None:
"""Approve this workspace's hook commands so they are allowed to run.

Hooks come from files a repository can commit, so cloning an untrusted repo
would otherwise be enough to get its shell commands executed (#905). The
decision is recorded outside the repository tree and keyed to these exact
commands, so editing a hook requires approving it again.
"""
from codeframe.core.config import load_environment_config
from codeframe.core.hook_trust import describe_hooks, record_trust
from codeframe.core.workspace import get_workspace

path = (workspace_path or Path.cwd()).resolve()
try:
path = get_workspace(path).repo_path
except (FileNotFoundError, ValueError):
pass # Workspace not initialized; fall back to raw path

config = load_environment_config(path)
described = describe_hooks(config.hooks) if config else ""
if not described:
console.print("[yellow]No hooks configured — nothing to trust.[/yellow]")
raise typer.Exit(1)

# Always show the exact commands before approval: they run as you.
console.print("[bold]These commands will run on this workspace's lifecycle events:[/bold]")
console.print(described)
if not yes and not typer.confirm("Trust these hooks?", default=False):
console.print("[yellow]Not trusted.[/yellow]")
raise typer.Exit(1)

record_trust(path, config.hooks)
console.print(f"[green]Hooks trusted for {path}[/green]")
86 changes: 86 additions & 0 deletions codeframe/core/agent_env.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""The environment every agent-triggered subprocess runs with (#721, #905).

A **leaf module**: stdlib only, so both ``core/tools.py`` (ReAct engine) and
``core/executor.py`` (legacy plan engine) can converge on it without a cycle.

Two things it guarantees:

* **No secrets in the environment** (#721) — an allowlist, so a credential added
to the operator's shell later is excluded by construction rather than by
remembering to blocklist it.
* **No pointer to secrets** (#905) — ``HOME`` and the ``XDG_*`` paths resolve
into a per-workspace scratch directory. The allowlist kept ``ANTHROPIC_API_KEY``
out; ``HOME`` would have handed over the directory holding it.

This is not containment. The subprocess still runs as the operator with normal
filesystem access, so a command naming an absolute path still reaches whatever
that path holds. Only OS-level isolation (worktree/E2B/container) contains a
hostile command; this closes the paths a prompt-injected agent actually takes.
"""

from __future__ import annotations

import os
from pathlib import Path

#: The agent's shell inherits a *deny-by-default* environment. Without this it
#: would receive every secret in the operator's shell — ANTHROPIC_API_KEY,
#: OPENAI_API_KEY, AUTH_SECRET, etc. These are the non-secret vars needed to run
#: typical build/test/git commands; PATH and VIRTUAL_ENV are adjusted below for
#: venv activation.
SAFE_ENV_VARS = frozenset({
"PATH", "HOME", "USER", "LOGNAME", "SHELL", "PWD", "TERM", "TZ",
"LANG", "LANGUAGE", "LC_ALL", "LC_CTYPE",
"TMPDIR", "TMP", "TEMP",
"PYTHONPATH", "PYTHONUNBUFFERED", "PYTHONDONTWRITEBYTECODE", "VIRTUAL_ENV",
"NODE_ENV", "NODE_PATH", "GOPATH", "GOCACHE", "CARGO_HOME", "RUSTUP_HOME",
"JAVA_HOME", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
"SYSTEMROOT", "SYSTEMDRIVE", "COMSPEC", # Windows shell essentials
# Proxy config + CI signal (infra, not secrets) so npm/pip/curl and
# CI-aware test runners work; git identity for agent commits (#721 review).
"HTTP_PROXY", "HTTPS_PROXY", "FTP_PROXY", "ALL_PROXY", "NO_PROXY",
"http_proxy", "https_proxy", "ftp_proxy", "all_proxy", "no_proxy",
"CI",
"GIT_AUTHOR_NAME", "GIT_AUTHOR_EMAIL",
"GIT_COMMITTER_NAME", "GIT_COMMITTER_EMAIL",
})

#: These default to ``$HOME/...`` when unset, so pinning ``HOME`` alone would
#: still let an XDG path resolve back to the operator's real home.
_XDG_VARS = ("XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME")


def build_agent_env(workspace_path: Path) -> 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.
"""
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)

for venv_dir in (".venv", "venv"):
venv_bin = workspace_path / venv_dir / "bin"
if venv_bin.is_dir():
env["PATH"] = str(venv_bin) + os.pathsep + env.get("PATH", "")
env["VIRTUAL_ENV"] = str(workspace_path / venv_dir)
break

return env
18 changes: 14 additions & 4 deletions codeframe/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
"""

import json
import logging
from dataclasses import dataclass, field as dataclass_field, asdict
from enum import Enum
from pathlib import Path
Expand All @@ -21,6 +22,8 @@
from pydantic import BaseModel, Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

logger = logging.getLogger(__name__)


# =============================================================================
# v2 Environment Configuration (YAML-based)
Expand Down Expand Up @@ -428,11 +431,18 @@ def _codeframe_config_to_env_config(cf_config: Any) -> EnvironmentConfig:
lint_gate_names = {"ruff", "pylint", "eslint", "prettier", "flake8", "mypy", "biome"}
config.lint_tools = [g for g in cf_config.gates if g in lint_gate_names]

# Map hooks
# Hooks are deliberately NOT mapped from CODEFRAME.md (#905). That file is
# found by walking *up* from the workspace, so a CODEFRAME.md in a parent
# directory — or one committed by a cloned repo — could supply shell
# commands that `cf init` runs immediately. Hooks come only from
# .codeframe/config.yaml, and even there they need a recorded trust
# decision (core.hook_trust).
if cf_config.hooks:
valid_hook_fields = {f.name for f in HooksConfig.__dataclass_fields__.values()}
filtered = {k: v for k, v in cf_config.hooks.items() if k in valid_hook_fields}
config.hooks = HooksConfig(**filtered)
logger.warning(
"Ignoring %d hook(s) declared in CODEFRAME.md: hooks may only be "
"configured in .codeframe/config.yaml (#905).",
len(cf_config.hooks),
)

# Map batch
if cf_config.batch:
Expand Down
7 changes: 7 additions & 0 deletions codeframe/core/dangerous_commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,13 @@
(r"\b(wget|curl)\s+.*\|\s*(ba)?sh", "download piped to shell"),
# Overwriting important system files
(r">\s*/(etc|bin|usr|lib|sbin)/", "overwriting system directory"),
# Reaching the operator's credential store (#905). run_command already gets a
# sandboxed HOME, so `~/.codeframe` and `$HOME/.codeframe` resolve into the
# workspace — this catches the absolute path an agent can still discover via
# `ls /home`. Defense in depth against prompt injection, not a containment
# boundary: an obfuscated path defeats it, and only OS-level isolation
# (worktree/E2B/container) actually contains a hostile command.
(r"\.codeframe/credentials", "reading the credential store"),
]


Expand Down
9 changes: 9 additions & 0 deletions codeframe/core/executor.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
from pathlib import Path
from typing import Optional, TYPE_CHECKING

from codeframe.core.agent_env import build_agent_env
from codeframe.core.planner import PlanStep, StepType, ImplementationPlan
from codeframe.core.context import TaskContext
from codeframe.adapters.llm import LLMProvider, Purpose
Expand Down Expand Up @@ -498,6 +499,11 @@ def _execute_shell_command(self, step: PlanStep) -> StepResult:
shell_operators = ['|', '&&', '||', '>', '<', '>>', '<<', ';', '`', '$(']
requires_shell = any(op in command for op in shell_operators)

# Same credential-free env + sandboxed HOME as the ReAct engine (#905
# review). These commands are LLM-authored and run in a workspace whose
# contents may be untrusted, so the legacy engine gets the same floor.
env = build_agent_env(Path(self.repo_path))

try:
if requires_shell:
# Command contains shell operators, must use shell=True
Expand All @@ -508,6 +514,7 @@ def _execute_shell_command(self, step: PlanStep) -> StepResult:
capture_output=True,
text=True,
timeout=self.command_timeout,
env=env,
)
else:
# Safe to use shell=False with parsed arguments
Expand All @@ -520,6 +527,7 @@ def _execute_shell_command(self, step: PlanStep) -> StepResult:
capture_output=True,
text=True,
timeout=self.command_timeout,
env=env,
)
except ValueError:
# shlex.split failed (malformed command), fall back to shell=True
Expand All @@ -530,6 +538,7 @@ def _execute_shell_command(self, step: PlanStep) -> StepResult:
capture_output=True,
text=True,
timeout=self.command_timeout,
env=env,
)

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