From d9ccd2da01a0458ab80f9e58bcc39135adfbacd8 Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Mon, 26 Oct 2026 15:22:33 -0700 Subject: [PATCH 1/2] fix(cli): sandbox the claude-code harness and run the local ACP on loopback VULN-3136. The three claude-code templates (and their tutorials) spawned `claude -p` in the ACP's working directory, which under `agentex agents run` is the project directory holding .env, with the ACP's full environment and no tool policy, while `agentex agents run` served the unauthenticated ACP on 0.0.0.0. - Spawn the CLI in a per-task scratch directory under the temp dir (stable across turns, so Temporal `-r ` resume still works), with only ANTHROPIC_API_KEY, PATH, HOME and USER in its environment. - Pass an explicit policy: read-only tools (--tools Read,Glob,Grep), --permission-mode dontAsk, no setting sources, --strict-mcp-config. - Pin @anthropic-ai/claude-code@2.1.142 in the template and tutorial Dockerfiles, the version the tutorials CI installs. - `agentex agents run` now listens on 127.0.0.1. Override with --host 0.0.0.0 or AGENTEX_AGENT_HOST=0.0.0.0; the tutorials CI sets the variable because its Docker server reaches agents through host-gateway. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/agentex-tutorials-test.yml | 4 + README.md | 6 + .../00_sync/060_claude_code/Dockerfile | 2 +- .../00_sync/060_claude_code/project/acp.py | 32 ++++- .../00_base/130_claude_code/Dockerfile | 2 +- .../00_base/130_claude_code/project/acp.py | 32 ++++- .../10_temporal/140_claude_code/Dockerfile | 2 +- .../140_claude_code/project/activities.py | 38 ++++- src/agentex/lib/cli/commands/agents.py | 10 +- src/agentex/lib/cli/debug/debug_handlers.py | 9 +- .../lib/cli/handlers/agent_handlers.py | 6 +- src/agentex/lib/cli/handlers/run_handlers.py | 17 ++- .../default-claude-code/Dockerfile-uv.j2 | 4 +- .../default-claude-code/Dockerfile.j2 | 4 +- .../default-claude-code/README.md.j2 | 11 +- .../default-claude-code/project/acp.py.j2 | 32 ++++- .../sync-claude-code/Dockerfile-uv.j2 | 4 +- .../templates/sync-claude-code/Dockerfile.j2 | 4 +- .../templates/sync-claude-code/README.md.j2 | 11 +- .../sync-claude-code/project/acp.py.j2 | 32 ++++- .../temporal-claude-code/Dockerfile-uv.j2 | 4 +- .../temporal-claude-code/Dockerfile.j2 | 4 +- .../temporal-claude-code/README.md.j2 | 11 +- .../project/activities.py.j2 | 38 ++++- tests/lib/cli/test_agents_run_host.py | 50 +++++++ .../lib/cli/test_claude_code_spawn_policy.py | 132 ++++++++++++++++++ 26 files changed, 463 insertions(+), 38 deletions(-) create mode 100644 tests/lib/cli/test_agents_run_host.py create mode 100644 tests/lib/cli/test_claude_code_spawn_policy.py diff --git a/.github/workflows/agentex-tutorials-test.yml b/.github/workflows/agentex-tutorials-test.yml index 2d0e23371..43fbecc83 100644 --- a/.github/workflows/agentex-tutorials-test.yml +++ b/.github/workflows/agentex-tutorials-test.yml @@ -184,6 +184,10 @@ jobs: # command line below is a different variable, read only by the tutorials' own test # files, and never reaches the SDK. AGENTEX_BASE_URL: http://localhost:5003 + # `agentex agents run` listens on 127.0.0.1 by default. The AgentEx server container + # dials agents at host.docker.internal, which host-gateway maps to the Docker bridge, + # not loopback, so agents here must listen on every interface. + AGENTEX_AGENT_HOST: 0.0.0.0 run: | echo "Testing tutorial: ${{ matrix.tutorial }}" AGENTEX_API_BASE_URL="http://localhost:5003" \ diff --git a/README.md b/README.md index cfe1611d8..ebbcbcdda 100644 --- a/README.md +++ b/README.md @@ -68,6 +68,12 @@ asyncio.run(main()) Functionality between the synchronous and asynchronous clients is otherwise identical. +## Running agents locally + +`agentex agents run --manifest manifest.yaml` serves the agent on `127.0.0.1`, because the local server has no +authentication. If the AgentEx server reaches your agent from a Linux container through `host-gateway`, or from another +machine, pass `--host 0.0.0.0` or set `AGENTEX_AGENT_HOST=0.0.0.0`. Anyone who can reach that port can then call the agent. + ## Debugging AgentEx provides built-in debugging support for **temporal projects** during local development. diff --git a/examples/tutorials/00_sync/060_claude_code/Dockerfile b/examples/tutorials/00_sync/060_claude_code/Dockerfile index ec22d7e0b..e67644e21 100644 --- a/examples/tutorials/00_sync/060_claude_code/Dockerfile +++ b/examples/tutorials/00_sync/060_claude_code/Dockerfile @@ -24,7 +24,7 @@ RUN uv pip install --system --upgrade pip setuptools wheel # Install the claude CLI (requires Node.js) # NOTE: live runs require ANTHROPIC_API_KEY in the environment. -RUN npm install -g @anthropic-ai/claude-code || true +RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true ENV UV_HTTP_TIMEOUT=1000 diff --git a/examples/tutorials/00_sync/060_claude_code/project/acp.py b/examples/tutorials/00_sync/060_claude_code/project/acp.py index aad53801a..a66cc0b75 100644 --- a/examples/tutorials/00_sync/060_claude_code/project/acp.py +++ b/examples/tutorials/00_sync/060_claude_code/project/acp.py @@ -16,7 +16,10 @@ import os import asyncio +import hashlib +import tempfile from typing import AsyncIterator, AsyncGenerator +from pathlib import Path from dotenv import load_dotenv @@ -45,8 +48,30 @@ acp = FastACP.create(acp_type="sync") +# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools, +# denies anything that would need approval (such as reads outside its working +# directory), and ignores the developer's own Claude Code settings, hooks, +# plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: +async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. This is a seam: tests replace it with a fake async iterator of @@ -58,6 +83,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]: "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -132,6 +160,6 @@ async def handle_message_send( trace_id=task_id, parent_span_id=turn_span.id if turn_span else None, ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) + turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id)) async for event in emitter.yield_turn(turn): yield event diff --git a/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile b/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile index e36b9e56d..35e2474a1 100644 --- a/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile +++ b/examples/tutorials/10_async/00_base/130_claude_code/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \ RUN uv pip install --system --upgrade pip setuptools wheel -RUN npm install -g @anthropic-ai/claude-code || true +RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true ENV UV_HTTP_TIMEOUT=1000 diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py index b6681f6a8..9f3c52a0d 100644 --- a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py +++ b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py @@ -15,7 +15,10 @@ import os import asyncio +import hashlib +import tempfile from typing import AsyncIterator +from pathlib import Path from dotenv import load_dotenv @@ -46,8 +49,30 @@ config=AsyncACPConfig(type="base"), ) +# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools, +# denies anything that would need approval (such as reads outside its working +# directory), and ignores the developer's own Claude Code settings, hooks, +# plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: +async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. Injectable seam: tests monkeypatch this with a fake async iterator of @@ -59,6 +84,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]: "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -138,7 +166,7 @@ async def handle_task_event_send(params: SendEventParams): trace_id=task_id, parent_span_id=turn_span.id if turn_span else None, ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) + turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id)) result = await emitter.auto_send_turn(turn) if turn_span: turn_span.output = {"final_text": result.final_text} diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile b/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile index c909ee6c7..dc34cafb5 100644 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile @@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \ RUN uv pip install --system --upgrade pip setuptools wheel -RUN npm install -g @anthropic-ai/claude-code || true +RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true ENV UV_HTTP_TIMEOUT=1000 diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py index dcba0f9a7..aca64c768 100644 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py @@ -14,8 +14,12 @@ from __future__ import annotations +import os import asyncio +import hashlib +import tempfile from typing import Any, AsyncIterator +from pathlib import Path from datetime import datetime from temporalio import activity @@ -48,7 +52,34 @@ class RunClaudeCodeTurnResult(BaseModel): session_id: str | None = None -async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: +# Whoever can reach this agent's ACP writes the prompt, so the CLI gets read-only +# tools, denies anything that would need approval (such as reads outside its +# working directory), and ignores the developer's own Claude Code settings, +# hooks, plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env). + + Stable across turns: the CLI files sessions by working directory, so + ``-r `` only finds the previous turn from the same directory. + """ + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + + +async def _spawn_claude(prompt: str, task_id: str, session_id: str | None = None) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. Pass ``session_id`` to resume a previous Claude Code session (multi-turn @@ -63,12 +94,15 @@ async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIter "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, ] if session_id: cmd.extend(["-r", session_id]) proc = await asyncio.create_subprocess_exec( *cmd, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -133,7 +167,7 @@ async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any trace_id=params.trace_id, parent_span_id=params.parent_span_id, ) - turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) + turn = ClaudeCodeTurn(_spawn_claude(params.prompt, params.task_id, session_id=params.session_id)) result = await emitter.auto_send_turn(turn, created_at=params.created_at) return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/src/agentex/lib/cli/commands/agents.py b/src/agentex/lib/cli/commands/agents.py index b4076d932..427089a41 100644 --- a/src/agentex/lib/cli/commands/agents.py +++ b/src/agentex/lib/cli/commands/agents.py @@ -22,6 +22,7 @@ validate_namespace, check_and_switch_cluster_context, ) +from agentex.lib.cli.handlers.run_handlers import DEFAULT_ACP_HOST from agentex.lib.sdk.config.agent_manifest import load_agent_manifest from agentex.lib.cli.handlers.agent_handlers import ( run_agent, @@ -277,6 +278,13 @@ def run( debug_acp: bool = typer.Option(False, help="Enable debug mode for ACP server only"), debug_port: int = typer.Option(5678, help="Port for remote debugging (worker uses this, ACP uses port+1)"), wait_for_debugger: bool = typer.Option(False, help="Wait for debugger to attach before starting"), + host: str = typer.Option( + DEFAULT_ACP_HOST, + envvar="AGENTEX_AGENT_HOST", + help="Interface the local ACP server listens on. It has no authentication, so use 0.0.0.0 only when " + "something off this machine must reach it, such as an agentex server in a Linux container that " + "dials host.docker.internal through host-gateway.", + ), ) -> None: """ Run an agent locally from the given manifest. @@ -326,7 +334,7 @@ def run( console.print("[yellow]⏳ Processes will wait for debugger attachment[/yellow]") try: - run_agent(manifest_path=manifest, debug_config=debug_config) + run_agent(manifest_path=manifest, debug_config=debug_config, host=host) except Exception as e: typer.echo(f"Error running agent: {str(e)}", err=True) logger.exception("Error running agent") diff --git a/src/agentex/lib/cli/debug/debug_handlers.py b/src/agentex/lib/cli/debug/debug_handlers.py index a27d682cd..998f23501 100644 --- a/src/agentex/lib/cli/debug/debug_handlers.py +++ b/src/agentex/lib/cli/debug/debug_handlers.py @@ -72,10 +72,11 @@ async def start_temporal_worker_debug( async def start_acp_server_debug( - acp_path: Path, - port: int, + acp_path: Path, + port: int, env: Dict[str, str], - debug_config: DebugConfig + debug_config: DebugConfig, + host: str = "127.0.0.1", ): """Start ACP server with debug support""" @@ -103,7 +104,7 @@ async def start_acp_server_debug( "--port", str(port), "--host", - "0.0.0.0", + host, # Note: No --reload flag when debugging ] diff --git a/src/agentex/lib/cli/handlers/agent_handlers.py b/src/agentex/lib/cli/handlers/agent_handlers.py index 3c966896e..28cc7437e 100644 --- a/src/agentex/lib/cli/handlers/agent_handlers.py +++ b/src/agentex/lib/cli/handlers/agent_handlers.py @@ -8,7 +8,7 @@ from agentex.lib.cli.debug import DebugConfig from agentex.lib.utils.logging import make_logger -from agentex.lib.cli.handlers.run_handlers import RunError, run_agent as _run_agent +from agentex.lib.cli.handlers.run_handlers import DEFAULT_ACP_HOST, RunError, run_agent as _run_agent from agentex.lib.sdk.config.agent_manifest import BuildContextManager, load_agent_manifest, build_context_manager logger = make_logger(__name__) @@ -138,7 +138,7 @@ def build_agent( return image_name -def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): +def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None, host: str = DEFAULT_ACP_HOST): """Run an agent locally from the given manifest""" import sys import signal @@ -164,7 +164,7 @@ def signal_handler(signum, _frame): signal.signal(signal.SIGTERM, signal_handler) try: - asyncio.run(_run_agent(manifest_path, debug_config)) + asyncio.run(_run_agent(manifest_path, debug_config, host)) except KeyboardInterrupt: logger.info("Shutdown completed.") sys.exit(0) diff --git a/src/agentex/lib/cli/handlers/run_handlers.py b/src/agentex/lib/cli/handlers/run_handlers.py index 18ee84e93..fcb954bec 100644 --- a/src/agentex/lib/cli/handlers/run_handlers.py +++ b/src/agentex/lib/cli/handlers/run_handlers.py @@ -24,6 +24,9 @@ logger = make_logger(__name__) console = Console() +# The local ACP has no authentication, so it listens on loopback unless told otherwise. +DEFAULT_ACP_HOST = "127.0.0.1" + # How many consecutive unreadable lines to skip before giving up on the stream. # Skipping is only known-safe for the limit-overrun case; this bounds the damage # if some other error repeats without consuming anything. @@ -194,7 +197,7 @@ async def start_worker() -> asyncio.subprocess.Process: async def start_acp_server( - acp_path: Path, port: int, env: dict[str, str], manifest_dir: Path + acp_path: Path, port: int, env: dict[str, str], manifest_dir: Path, host: str = DEFAULT_ACP_HOST ) -> asyncio.subprocess.Process: """Start the ACP server process""" # Use file path relative to manifest directory if possible @@ -211,10 +214,10 @@ async def start_acp_server( "--port", str(port), "--host", - "0.0.0.0", + host, ] - console.print(f"[blue]Starting ACP server from {acp_path} on port {port}...[/blue]") + console.print(f"[blue]Starting ACP server from {acp_path} on {host}:{port}...[/blue]") return await asyncio.create_subprocess_exec( *cmd, cwd=manifest_dir, # Always use manifest directory as CWD for consistency @@ -305,7 +308,9 @@ async def stream_process_output(process: asyncio.subprocess.Process, prefix: str ) -async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = None): +async def run_agent( + manifest_path: str, debug_config: "DebugConfig | None" = None, host: str = DEFAULT_ACP_HOST +): """Run an agent locally from the given manifest""" # Validate manifest exists @@ -349,11 +354,11 @@ async def run_agent(manifest_path: str, debug_config: "DebugConfig | None" = Non manifest_dir = Path(manifest_path).parent if debug_config and debug_config.should_debug_acp(): acp_process = await start_acp_server_debug( - file_paths["acp"], manifest.local_development.agent.port, agent_env, debug_config # type: ignore[union-attr] + file_paths["acp"], manifest.local_development.agent.port, agent_env, debug_config, host # type: ignore[union-attr] ) else: acp_process = await start_acp_server( - file_paths["acp"], manifest.local_development.agent.port, agent_env, manifest_dir # type: ignore[union-attr] + file_paths["acp"], manifest.local_development.agent.port, agent_env, manifest_dir, host # type: ignore[union-attr] ) process_manager.add_process(acp_process) diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 index 8a22d0f89..1df9d8387 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile-uv.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the agent shells out to `claude` on every turn, # so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 ENV UV_COMPILE_BYTECODE=1 ENV UV_LINK_MODE=copy diff --git a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 index 3556f6dfd..9840ca254 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/Dockerfile.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the agent shells out to `claude` on every turn, # so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 RUN uv pip install --system --upgrade pip setuptools wheel diff --git a/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 index ab05398e3..738175377 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/README.md.j2 @@ -52,7 +52,11 @@ delivers them. ### 1. Customize the subprocess Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working -directory, or how the prompt is delivered. +directory, or how the prompt is delivered. Whoever can reach the agent writes +the prompt, so by default the CLI gets only read-only tools (`CLAUDE_POLICY_ARGS`), +runs in a per-task scratch directory outside this project, and sees only the +variables in `CLAUDE_ENV_KEYS` (add, say, `ANTHROPIC_BASE_URL` or `HTTPS_PROXY` +there if your setup needs them). Loosen the rest only if every caller is trusted. ### 2. Configure Credentials Set your credentials via `manifest.yaml`, an exported environment variable, or a @@ -62,3 +66,8 @@ Set your credentials via `manifest.yaml`, an exported environment variable, or a ```bash export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml ``` + +The agent listens on `127.0.0.1`. If the AgentEx server reaches it from a Linux +container through `host-gateway`, add `--host 0.0.0.0` (or set +`AGENTEX_AGENT_HOST=0.0.0.0`); anyone who can reach that port can then send it +prompts. diff --git a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 index 42512c601..551c6f5a5 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 @@ -13,7 +13,10 @@ from __future__ import annotations import os import asyncio +import hashlib +import tempfile from typing import AsyncIterator +from pathlib import Path from collections import deque from dotenv import load_dotenv @@ -46,8 +49,30 @@ acp = FastACP.create( config=AsyncACPConfig(type="base"), ) +# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools, +# denies anything that would need approval (such as reads outside its working +# directory), and ignores the developer's own Claude Code settings, hooks, +# plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: +async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. Injectable seam: tests can monkeypatch this with a fake async iterator of @@ -59,6 +84,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]: "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -156,7 +184,7 @@ async def handle_task_event_send(params: SendEventParams): trace_id=task_id, parent_span_id=turn_span.id if turn_span else None, ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) + turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id)) result = await emitter.auto_send_turn(turn) if turn_span: turn_span.output = {"final_text": result.final_text} diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 index 8a22d0f89..1df9d8387 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile-uv.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the agent shells out to `claude` on every turn, # so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 ENV UV_COMPILE_BYTECODE=1 ENV UV_LINK_MODE=copy diff --git a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 index cd0338d18..b561caaf6 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/Dockerfile.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the agent shells out to `claude` on every turn, # so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 RUN uv pip install --system --upgrade pip setuptools wheel diff --git a/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 index 7e38eddec..64fcb7854 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/README.md.j2 @@ -52,7 +52,11 @@ delivers them. ### 1. Customize the subprocess Edit `_spawn_claude` in `project/acp.py` to change the CLI flags, working -directory, or how the prompt is delivered. +directory, or how the prompt is delivered. Whoever can reach the agent writes +the prompt, so by default the CLI gets only read-only tools (`CLAUDE_POLICY_ARGS`), +runs in a per-task scratch directory outside this project, and sees only the +variables in `CLAUDE_ENV_KEYS` (add, say, `ANTHROPIC_BASE_URL` or `HTTPS_PROXY` +there if your setup needs them). Loosen the rest only if every caller is trusted. ### 2. Configure Credentials Set your credentials via `manifest.yaml`, an exported environment variable, or a @@ -62,3 +66,8 @@ Set your credentials via `manifest.yaml`, an exported environment variable, or a ```bash export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml ``` + +The agent listens on `127.0.0.1`. If the AgentEx server reaches it from a Linux +container through `host-gateway`, add `--host 0.0.0.0` (or set +`AGENTEX_AGENT_HOST=0.0.0.0`); anyone who can reach that port can then send it +prompts. diff --git a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 index 33a89a51e..60f231d8a 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 @@ -14,7 +14,10 @@ from __future__ import annotations import os import asyncio +import hashlib +import tempfile from typing import AsyncIterator, AsyncGenerator +from pathlib import Path from collections import deque from dotenv import load_dotenv @@ -45,8 +48,30 @@ add_tracing_processor_config( acp = FastACP.create(acp_type="sync") +# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools, +# denies anything that would need approval (such as reads outside its working +# directory), and ignores the developer's own Claude Code settings, hooks, +# plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + -async def _spawn_claude(prompt: str) -> AsyncIterator[str]: +async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. This is a seam: tests can replace it with a fake async iterator of @@ -58,6 +83,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]: "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -150,6 +178,6 @@ async def handle_message_send( trace_id=task_id, parent_span_id=turn_span.id if turn_span else None, ) - turn = ClaudeCodeTurn(_spawn_claude(prompt)) + turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id)) async for event in emitter.yield_turn(turn): yield event diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 index 1665bceb1..6728ace87 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile-uv.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the activity shells out to `claude` on every # turn, so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 # Install tctl (Temporal CLI) RUN ARCH="$(uname -m)" && \ diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 index 1297b7bd7..cc91f0a05 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/Dockerfile.j2 @@ -22,7 +22,9 @@ RUN apt-get update && apt-get install -y \ # Install the Claude Code CLI: the activity shells out to `claude` on every # turn, so the binary must be present in the runtime image. -RUN npm install -g @anthropic-ai/claude-code +# Pinned: the spawn passes policy flags (--tools, --permission-mode dontAsk) that +# this version supports. Bump it with CLAUDE_CODE_VERSION in the tutorials CI. +RUN npm install -g @anthropic-ai/claude-code@2.1.142 # Install tctl (Temporal CLI) RUN ARCH="$(uname -m)" && \ diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 index 35ac019b5..f68e523e9 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/README.md.j2 @@ -61,7 +61,11 @@ delivers them. ### 1. Customize the subprocess Edit `_spawn_claude` in `project/activities.py` to change the CLI flags, working -directory, or how the prompt is delivered. +directory, or how the prompt is delivered. Whoever can reach the agent writes +the prompt, so by default the CLI gets only read-only tools (`CLAUDE_POLICY_ARGS`), +runs in a per-task scratch directory outside this project, and sees only the +variables in `CLAUDE_ENV_KEYS` (add, say, `ANTHROPIC_BASE_URL` or `HTTPS_PROXY` +there if your setup needs them). Loosen the rest only if every caller is trusted. ### 2. Configure Credentials Set your credentials via `manifest.yaml`, an exported environment variable, or a @@ -71,3 +75,8 @@ Set your credentials via `manifest.yaml`, an exported environment variable, or a ```bash export ENVIRONMENT=development && agentex agents run --manifest manifest.yaml ``` + +The agent listens on `127.0.0.1`. If the AgentEx server reaches it from a Linux +container through `host-gateway`, add `--host 0.0.0.0` (or set +`AGENTEX_AGENT_HOST=0.0.0.0`); anyone who can reach that port can then send it +prompts. diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 index 94055c7df..5782bb610 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 @@ -14,8 +14,12 @@ can provide a fake that yields pre-recorded stdout lines so no real CLI runs. from __future__ import annotations +import os import asyncio +import hashlib +import tempfile from typing import Any, AsyncIterator +from pathlib import Path from datetime import datetime from collections import deque @@ -49,7 +53,34 @@ class RunClaudeCodeTurnResult(BaseModel): session_id: str | None = None -async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]: +# Whoever can reach this agent's ACP writes the prompt, so the CLI gets read-only +# tools, denies anything that would need approval (such as reads outside its +# working directory), and ignores the developer's own Claude Code settings, +# hooks, plugins and MCP servers. Widen this only if every caller is trusted. +CLAUDE_POLICY_ARGS = ( + "--tools", "Read,Glob,Grep", + "--permission-mode", "dontAsk", + "--setting-sources", "", + "--strict-mcp-config", +) +# The only variables the CLI sees; USER lets it find a macOS keychain login. +CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER") +CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" + + +def _claude_workdir(task_id: str) -> Path: + """Per-task scratch directory for the CLI, outside this project (which holds .env). + + Stable across turns: the CLI files sessions by working directory, so + ``-r `` only finds the previous turn from the same directory. + """ + # Hashed because the task id comes from the request and must not steer the path. + workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] + workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + return workdir + + +async def _spawn_claude(prompt: str, task_id: str, session_id: str | None = None) -> AsyncIterator[str]: """Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines. Pass ``session_id`` to resume a previous Claude Code session (multi-turn @@ -64,12 +95,15 @@ async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIter "--output-format", "stream-json", "--verbose", + *CLAUDE_POLICY_ARGS, ] if session_id: cmd.extend(["-r", session_id]) proc = await asyncio.create_subprocess_exec( *cmd, + cwd=_claude_workdir(task_id), + env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ}, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE, @@ -149,7 +183,7 @@ async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any trace_id=params.trace_id, parent_span_id=params.parent_span_id, ) - turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id)) + turn = ClaudeCodeTurn(_spawn_claude(params.prompt, params.task_id, session_id=params.session_id)) result = await emitter.auto_send_turn(turn, created_at=params.created_at) return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump() diff --git a/tests/lib/cli/test_agents_run_host.py b/tests/lib/cli/test_agents_run_host.py new file mode 100644 index 000000000..feda73f22 --- /dev/null +++ b/tests/lib/cli/test_agents_run_host.py @@ -0,0 +1,50 @@ +"""`agentex agents run` keeps the unauthenticated local ACP on loopback unless told otherwise (VULN-3136).""" + +from __future__ import annotations + +import asyncio +from typing import Any + +import pytest +from typer.testing import CliRunner + +from agentex.lib.cli.debug import DebugMode, DebugConfig +from agentex.lib.cli.commands import agents as agents_command +from agentex.lib.cli.handlers import run_handlers +from agentex.lib.cli.debug.debug_handlers import start_acp_server_debug + + +@pytest.mark.parametrize( + ("args", "env", "expected"), + [ + ([], {}, "127.0.0.1"), + (["--host", "0.0.0.0"], {}, "0.0.0.0"), + ([], {"AGENTEX_AGENT_HOST": "0.0.0.0"}, "0.0.0.0"), + ], +) +def test_run_command_host(monkeypatch: pytest.MonkeyPatch, args: list[str], env: dict[str, str], expected: str): + monkeypatch.delenv("AGENTEX_AGENT_HOST", raising=False) + seen: dict[str, Any] = {} + monkeypatch.setattr(agents_command, "run_agent", lambda **kwargs: seen.update(kwargs)) + + result = CliRunner().invoke(agents_command.agents, ["run", "--manifest", "manifest.yaml", *args], env=env) + + assert result.exit_code == 0, result.output + assert seen["host"] == expected + + +async def test_acp_server_listens_on_the_given_host(monkeypatch: pytest.MonkeyPatch, tmp_path: Any): + hosts: list[str] = [] + + async def fake_exec(*args: Any, **_kwargs: Any) -> None: + hosts.append(args[args.index("--host") + 1]) + + monkeypatch.setattr(asyncio, "create_subprocess_exec", fake_exec) + monkeypatch.setattr(run_handlers, "calculate_uvicorn_target_for_local", lambda *_: "project.acp") + debug_config = DebugConfig(enabled=True, mode=DebugMode.ACP, port=5678, wait_for_attach=False, auto_port=False) + + await run_handlers.start_acp_server(tmp_path / "acp.py", 8000, {}, tmp_path) + await start_acp_server_debug(tmp_path / "acp.py", 8000, {}, debug_config) + await run_handlers.start_acp_server(tmp_path / "acp.py", 8000, {}, tmp_path, host="0.0.0.0") + + assert hosts == ["127.0.0.1", "127.0.0.1", "0.0.0.0"] diff --git a/tests/lib/cli/test_claude_code_spawn_policy.py b/tests/lib/cli/test_claude_code_spawn_policy.py new file mode 100644 index 000000000..45e44163d --- /dev/null +++ b/tests/lib/cli/test_claude_code_spawn_policy.py @@ -0,0 +1,132 @@ +"""The claude-code templates run the CLI sandboxed (VULN-3136). + +Whoever can reach a template agent's ACP writes the prompt, so the spawned +``claude`` must get read-only tools, a scratch working directory outside the +project (which holds ``.env``), and none of the agent's own secrets. + +Each rendered spawn runs in a child interpreter (importing a rendered ACP module +registers global tracing config) against a stub ``claude`` on PATH. +""" + +from __future__ import annotations + +import sys +import json +import subprocess +from pathlib import Path + +import pytest + +from agentex.lib.cli.commands.init import TemplateType + +from .test_init_templates import _render_project + +SPAWN_MODULES = { + TemplateType.SYNC_CLAUDE_CODE: "project.acp", + TemplateType.DEFAULT_CLAUDE_CODE: "project.acp", + TemplateType.TEMPORAL_CLAUDE_CODE: "project.activities", +} + +# Records each spawn the rendered module makes, then lets it run the stub. +DRIVER = """ +import sys, json, asyncio, importlib + +sys.path.insert(0, ".") +module = importlib.import_module(sys.argv[1]) +real_exec = asyncio.create_subprocess_exec +spawns = [] + +async def recording_exec(*args, **kwargs): + spawns.append({"argv": list(args), "cwd": str(kwargs.get("cwd")), "env": kwargs.get("env")}) + return await real_exec(*args, **kwargs) + +asyncio.create_subprocess_exec = recording_exec + +async def main(): + for task_id, kwargs in json.loads(sys.argv[2]): + async for _ in module._spawn_claude("hi", task_id, **kwargs): + pass + +asyncio.run(main()) +print(json.dumps(spawns)) +""" + +STUB_CLAUDE = """#!{python} +import sys +sys.stdin.read() +print('{{"type": "result", "result": "ok"}}') +""" + + +def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Path, Path, list[dict]]: + """Render the template and run its _spawn_claude once per (task_id, kwargs) in calls.""" + project_dir = _render_project(tmp_path, template_type) + (project_dir / ".env").write_text("DOTENV_SECRET=CANARY-dotenv\n") + stub = tmp_path / "bin" / "claude" + stub.parent.mkdir() + stub.write_text(STUB_CLAUDE.format(python=sys.executable)) + stub.chmod(0o755) + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + env = { + "PATH": f"{stub.parent}:/usr/bin:/bin", + "HOME": str(tmp_path), + "USER": "dev", + "TMPDIR": str(tmp_dir), + "ANTHROPIC_API_KEY": "test-anthropic-key", + "AGENT_API_KEY": "CANARY-agent-api-key", + "SGP_API_KEY": "CANARY-sgp-api-key", + "AGENT_NAME": "my-agent", + "ACP_URL": "http://localhost", + } + # cwd = the project directory, as under `agentex agents run`. + result = subprocess.run( + [sys.executable, "-c", DRIVER, SPAWN_MODULES[template_type], json.dumps(calls)], + cwd=project_dir, + env=env, + capture_output=True, + text=True, + timeout=120, + check=False, + ) + assert result.returncode == 0, result.stderr + return project_dir, tmp_dir.resolve(), json.loads(result.stdout.strip().splitlines()[-1]) + + +@pytest.mark.parametrize("template_type", list(SPAWN_MODULES)) +def test_claude_runs_with_policy_scratch_dir_and_minimal_env(tmp_path: Path, template_type: TemplateType): + project_dir, tmp_dir, spawns = _spawn(tmp_path, template_type, [["task-1", {}], ["../../escape", {}]]) + first, hostile = spawns + argv = first["argv"] + + assert argv[0] == "claude" + # Only read-only tools exist for the model, so no Bash, Write, Edit or WebFetch. + assert argv[argv.index("--tools") + 1] == "Read,Glob,Grep" + # Anything that would need approval, such as a Read outside the working directory, is denied. + assert argv[argv.index("--permission-mode") + 1] == "dontAsk" + # The developer's settings (allow rules, extra directories, hooks) and MCP servers are ignored. + assert argv[argv.index("--setting-sources") + 1] == "" + assert "--strict-mcp-config" in argv + for widening in ("--add-dir", "--allowedTools", "--allowed-tools", "--dangerously-skip-permissions"): + assert widening not in argv + + workdir = Path(first["cwd"]).resolve() + assert workdir.parent == tmp_dir / "agentex-claude-code" + assert project_dir.resolve() not in (workdir, *workdir.parents) + # A crafted task id cannot steer the directory out of the scratch root. + assert Path(hostile["cwd"]).resolve().parent == workdir.parent + assert hostile["cwd"] != first["cwd"] + + env = first["env"] + assert set(env) == {"ANTHROPIC_API_KEY", "PATH", "HOME", "USER"} + assert "CANARY" not in json.dumps(spawns) + + +def test_temporal_resume_keeps_the_task_workdir(tmp_path: Path): + calls = [["task-1", {}], ["task-1", {"session_id": "sess-1"}], ["task-2", {}]] + _, _, (first, resumed, other) = _spawn(tmp_path, TemplateType.TEMPORAL_CLAUDE_CODE, calls) + + assert resumed["argv"][-2:] == ["-r", "sess-1"] + # The CLI files sessions by working directory, so resume must run where the first turn ran. + assert resumed["cwd"] == first["cwd"] + assert other["cwd"] != first["cwd"] From d38f5ce4b554a2a06b55cef92290e84789b8b47a Mon Sep 17 00:00:00 2001 From: Sirui Wang Date: Mon, 26 Oct 2026 15:36:19 -0700 Subject: [PATCH 2/2] fix(cli): refuse a claude-code scratch root another account could control On a shared /tmp another account could pre-create agentex-claude-code, or link it to the developer's project, and mkdir(exist_ok=True) would accept it. Check the root with lstat: a real directory, owned by this user, with no group or other access (POSIX only; the Windows temp dir is per-user). Co-Authored-By: Claude Opus 5.5 --- .../00_sync/060_claude_code/project/acp.py | 8 ++++- .../00_base/130_claude_code/project/acp.py | 8 ++++- .../140_claude_code/project/activities.py | 8 ++++- .../default-claude-code/project/acp.py.j2 | 8 ++++- .../sync-claude-code/project/acp.py.j2 | 8 ++++- .../project/activities.py.j2 | 8 ++++- .../lib/cli/test_claude_code_spawn_policy.py | 34 ++++++++++++++++--- 7 files changed, 72 insertions(+), 10 deletions(-) diff --git a/examples/tutorials/00_sync/060_claude_code/project/acp.py b/examples/tutorials/00_sync/060_claude_code/project/acp.py index a66cc0b75..36ec1ceed 100644 --- a/examples/tutorials/00_sync/060_claude_code/project/acp.py +++ b/examples/tutorials/00_sync/060_claude_code/project/acp.py @@ -15,6 +15,7 @@ from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -65,9 +66,14 @@ def _claude_workdir(task_id: str) -> Path: """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py index 9f3c52a0d..df156fa42 100644 --- a/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py +++ b/examples/tutorials/10_async/00_base/130_claude_code/project/acp.py @@ -14,6 +14,7 @@ from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -66,9 +67,14 @@ def _claude_workdir(task_id: str) -> Path: """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py index aca64c768..28d516872 100644 --- a/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py +++ b/examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py @@ -15,6 +15,7 @@ from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -73,9 +74,14 @@ def _claude_workdir(task_id: str) -> Path: Stable across turns: the CLI files sessions by working directory, so ``-r `` only finds the previous turn from the same directory. """ + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 index 551c6f5a5..81adea005 100644 --- a/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/default-claude-code/project/acp.py.j2 @@ -12,6 +12,7 @@ ANTHROPIC_API_KEY (or equivalent credential) in the environment. from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -66,9 +67,14 @@ CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" def _claude_workdir(task_id: str) -> Path: """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 index 60f231d8a..a22369c85 100644 --- a/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 +++ b/src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2 @@ -13,6 +13,7 @@ ANTHROPIC_API_KEY (or equivalent credential) to be in the environment. from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -65,9 +66,14 @@ CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code" def _claude_workdir(task_id: str) -> Path: """Per-task scratch directory for the CLI, outside this project (which holds .env).""" + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 index 5782bb610..7332b528c 100644 --- a/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 +++ b/src/agentex/lib/cli/templates/temporal-claude-code/project/activities.py.j2 @@ -15,6 +15,7 @@ can provide a fake that yields pre-recorded stdout lines so no real CLI runs. from __future__ import annotations import os +import stat import asyncio import hashlib import tempfile @@ -74,9 +75,14 @@ def _claude_workdir(task_id: str) -> Path: Stable across turns: the CLI files sessions by working directory, so ``-r `` only finds the previous turn from the same directory. """ + CLAUDE_WORKDIR_ROOT.mkdir(mode=0o700, exist_ok=True) + root = CLAUDE_WORKDIR_ROOT.lstat() + # The temp dir can be shared (Linux /tmp): refuse a root another account made or linked. + if hasattr(os, "getuid") and (not stat.S_ISDIR(root.st_mode) or root.st_uid != os.getuid() or root.st_mode & 0o077): + raise RuntimeError(f"{CLAUDE_WORKDIR_ROOT} is not a private directory owned by this user") # Hashed because the task id comes from the request and must not steer the path. workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32] - workdir.mkdir(mode=0o700, parents=True, exist_ok=True) + workdir.mkdir(mode=0o700, exist_ok=True) return workdir diff --git a/tests/lib/cli/test_claude_code_spawn_policy.py b/tests/lib/cli/test_claude_code_spawn_policy.py index 45e44163d..c693a62cd 100644 --- a/tests/lib/cli/test_claude_code_spawn_policy.py +++ b/tests/lib/cli/test_claude_code_spawn_policy.py @@ -10,6 +10,7 @@ from __future__ import annotations +import os import sys import json import subprocess @@ -58,7 +59,7 @@ async def main(): """ -def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Path, Path, list[dict]]: +def _run(tmp_path: Path, template_type: TemplateType, calls: list) -> subprocess.CompletedProcess[str]: """Render the template and run its _spawn_claude once per (task_id, kwargs) in calls.""" project_dir = _render_project(tmp_path, template_type) (project_dir / ".env").write_text("DOTENV_SECRET=CANARY-dotenv\n") @@ -67,7 +68,7 @@ def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Pa stub.write_text(STUB_CLAUDE.format(python=sys.executable)) stub.chmod(0o755) tmp_dir = tmp_path / "tmp" - tmp_dir.mkdir() + tmp_dir.mkdir(exist_ok=True) env = { "PATH": f"{stub.parent}:/usr/bin:/bin", "HOME": str(tmp_path), @@ -80,7 +81,7 @@ def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Pa "ACP_URL": "http://localhost", } # cwd = the project directory, as under `agentex agents run`. - result = subprocess.run( + return subprocess.run( [sys.executable, "-c", DRIVER, SPAWN_MODULES[template_type], json.dumps(calls)], cwd=project_dir, env=env, @@ -89,8 +90,12 @@ def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Pa timeout=120, check=False, ) + + +def _spawn(tmp_path: Path, template_type: TemplateType, calls: list) -> tuple[Path, Path, list[dict]]: + result = _run(tmp_path, template_type, calls) assert result.returncode == 0, result.stderr - return project_dir, tmp_dir.resolve(), json.loads(result.stdout.strip().splitlines()[-1]) + return tmp_path / "my_agent", (tmp_path / "tmp").resolve(), json.loads(result.stdout.strip().splitlines()[-1]) @pytest.mark.parametrize("template_type", list(SPAWN_MODULES)) @@ -122,6 +127,27 @@ def test_claude_runs_with_policy_scratch_dir_and_minimal_env(tmp_path: Path, tem assert "CANARY" not in json.dumps(spawns) +@pytest.mark.skipif(not hasattr(os, "getuid"), reason="the shared-temp-dir check is POSIX-only") +@pytest.mark.parametrize("planted", ["link", "world-writable"]) +@pytest.mark.parametrize("template_type", list(SPAWN_MODULES)) +def test_refuses_a_scratch_root_another_account_could_control( + tmp_path: Path, template_type: TemplateType, planted: str +): + # What another account could leave in a shared /tmp before the agent first runs. + root = tmp_path / "tmp" / "agentex-claude-code" + root.parent.mkdir() + if planted == "link": + root.symlink_to(tmp_path / "my_agent") + else: + root.mkdir() + root.chmod(0o777) + + result = _run(tmp_path, template_type, [["task-1", {}]]) + + assert result.returncode != 0 + assert "is not a private directory owned by this user" in result.stderr + + def test_temporal_resume_keeps_the_task_workdir(tmp_path: Path): calls = [["task-1", {}], ["task-1", {"session_id": "sess-1"}], ["task-2", {}]] _, _, (first, resumed, other) = _spawn(tmp_path, TemplateType.TEMPORAL_CLAUDE_CODE, calls)