Skip to content

Commit d9ccd2d

Browse files
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 <session_id>` 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 <noreply@anthropic.com>
1 parent 7e421c6 commit d9ccd2d

26 files changed

Lines changed: 463 additions & 38 deletions

File tree

‎.github/workflows/agentex-tutorials-test.yml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,10 @@ jobs:
184184
# command line below is a different variable, read only by the tutorials' own test
185185
# files, and never reaches the SDK.
186186
AGENTEX_BASE_URL: http://localhost:5003
187+
# `agentex agents run` listens on 127.0.0.1 by default. The AgentEx server container
188+
# dials agents at host.docker.internal, which host-gateway maps to the Docker bridge,
189+
# not loopback, so agents here must listen on every interface.
190+
AGENTEX_AGENT_HOST: 0.0.0.0
187191
run: |
188192
echo "Testing tutorial: ${{ matrix.tutorial }}"
189193
AGENTEX_API_BASE_URL="http://localhost:5003" \

‎README.md‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,12 @@ asyncio.run(main())
6868

6969
Functionality between the synchronous and asynchronous clients is otherwise identical.
7070

71+
## Running agents locally
72+
73+
`agentex agents run --manifest manifest.yaml` serves the agent on `127.0.0.1`, because the local server has no
74+
authentication. If the AgentEx server reaches your agent from a Linux container through `host-gateway`, or from another
75+
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.
76+
7177
## Debugging
7278

7379
AgentEx provides built-in debugging support for **temporal projects** during local development.

‎examples/tutorials/00_sync/060_claude_code/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ RUN uv pip install --system --upgrade pip setuptools wheel
2424

2525
# Install the claude CLI (requires Node.js)
2626
# NOTE: live runs require ANTHROPIC_API_KEY in the environment.
27-
RUN npm install -g @anthropic-ai/claude-code || true
27+
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true
2828

2929
ENV UV_HTTP_TIMEOUT=1000
3030

‎examples/tutorials/00_sync/060_claude_code/project/acp.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,10 @@
1616

1717
import os
1818
import asyncio
19+
import hashlib
20+
import tempfile
1921
from typing import AsyncIterator, AsyncGenerator
22+
from pathlib import Path
2023

2124
from dotenv import load_dotenv
2225

@@ -45,8 +48,30 @@
4548

4649
acp = FastACP.create(acp_type="sync")
4750

51+
# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools,
52+
# denies anything that would need approval (such as reads outside its working
53+
# directory), and ignores the developer's own Claude Code settings, hooks,
54+
# plugins and MCP servers. Widen this only if every caller is trusted.
55+
CLAUDE_POLICY_ARGS = (
56+
"--tools", "Read,Glob,Grep",
57+
"--permission-mode", "dontAsk",
58+
"--setting-sources", "",
59+
"--strict-mcp-config",
60+
)
61+
# The only variables the CLI sees; USER lets it find a macOS keychain login.
62+
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
63+
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"
64+
65+
66+
def _claude_workdir(task_id: str) -> Path:
67+
"""Per-task scratch directory for the CLI, outside this project (which holds .env)."""
68+
# Hashed because the task id comes from the request and must not steer the path.
69+
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
70+
workdir.mkdir(mode=0o700, parents=True, exist_ok=True)
71+
return workdir
72+
4873

49-
async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
74+
async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]:
5075
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.
5176
5277
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]:
5883
"--output-format",
5984
"stream-json",
6085
"--verbose",
86+
*CLAUDE_POLICY_ARGS,
87+
cwd=_claude_workdir(task_id),
88+
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
6189
stdin=asyncio.subprocess.PIPE,
6290
stdout=asyncio.subprocess.PIPE,
6391
stderr=asyncio.subprocess.PIPE,
@@ -132,6 +160,6 @@ async def handle_message_send(
132160
trace_id=task_id,
133161
parent_span_id=turn_span.id if turn_span else None,
134162
)
135-
turn = ClaudeCodeTurn(_spawn_claude(prompt))
163+
turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id))
136164
async for event in emitter.yield_turn(turn):
137165
yield event

‎examples/tutorials/10_async/00_base/130_claude_code/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \
2121

2222
RUN uv pip install --system --upgrade pip setuptools wheel
2323

24-
RUN npm install -g @anthropic-ai/claude-code || true
24+
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true
2525

2626
ENV UV_HTTP_TIMEOUT=1000
2727

‎examples/tutorials/10_async/00_base/130_claude_code/project/acp.py‎

Lines changed: 30 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,10 @@
1515

1616
import os
1717
import asyncio
18+
import hashlib
19+
import tempfile
1820
from typing import AsyncIterator
21+
from pathlib import Path
1922

2023
from dotenv import load_dotenv
2124

@@ -46,8 +49,30 @@
4649
config=AsyncACPConfig(type="base"),
4750
)
4851

52+
# Whoever can reach this ACP writes the prompt, so the CLI gets read-only tools,
53+
# denies anything that would need approval (such as reads outside its working
54+
# directory), and ignores the developer's own Claude Code settings, hooks,
55+
# plugins and MCP servers. Widen this only if every caller is trusted.
56+
CLAUDE_POLICY_ARGS = (
57+
"--tools", "Read,Glob,Grep",
58+
"--permission-mode", "dontAsk",
59+
"--setting-sources", "",
60+
"--strict-mcp-config",
61+
)
62+
# The only variables the CLI sees; USER lets it find a macOS keychain login.
63+
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
64+
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"
65+
66+
67+
def _claude_workdir(task_id: str) -> Path:
68+
"""Per-task scratch directory for the CLI, outside this project (which holds .env)."""
69+
# Hashed because the task id comes from the request and must not steer the path.
70+
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
71+
workdir.mkdir(mode=0o700, parents=True, exist_ok=True)
72+
return workdir
73+
4974

50-
async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
75+
async def _spawn_claude(prompt: str, task_id: str) -> AsyncIterator[str]:
5176
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.
5277
5378
Injectable seam: tests monkeypatch this with a fake async iterator of
@@ -59,6 +84,9 @@ async def _spawn_claude(prompt: str) -> AsyncIterator[str]:
5984
"--output-format",
6085
"stream-json",
6186
"--verbose",
87+
*CLAUDE_POLICY_ARGS,
88+
cwd=_claude_workdir(task_id),
89+
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
6290
stdin=asyncio.subprocess.PIPE,
6391
stdout=asyncio.subprocess.PIPE,
6492
stderr=asyncio.subprocess.PIPE,
@@ -138,7 +166,7 @@ async def handle_task_event_send(params: SendEventParams):
138166
trace_id=task_id,
139167
parent_span_id=turn_span.id if turn_span else None,
140168
)
141-
turn = ClaudeCodeTurn(_spawn_claude(prompt))
169+
turn = ClaudeCodeTurn(_spawn_claude(prompt, task_id))
142170
result = await emitter.auto_send_turn(turn)
143171
if turn_span:
144172
turn_span.output = {"final_text": result.final_text}

‎examples/tutorials/10_async/10_temporal/140_claude_code/Dockerfile‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ RUN apt-get update && apt-get install -y \
2121

2222
RUN uv pip install --system --upgrade pip setuptools wheel
2323

24-
RUN npm install -g @anthropic-ai/claude-code || true
24+
RUN npm install -g @anthropic-ai/claude-code@2.1.142 || true
2525

2626
ENV UV_HTTP_TIMEOUT=1000
2727

‎examples/tutorials/10_async/10_temporal/140_claude_code/project/activities.py‎

Lines changed: 36 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,12 @@
1414

1515
from __future__ import annotations
1616

17+
import os
1718
import asyncio
19+
import hashlib
20+
import tempfile
1821
from typing import Any, AsyncIterator
22+
from pathlib import Path
1923
from datetime import datetime
2024

2125
from temporalio import activity
@@ -48,7 +52,34 @@ class RunClaudeCodeTurnResult(BaseModel):
4852
session_id: str | None = None
4953

5054

51-
async def _spawn_claude(prompt: str, session_id: str | None = None) -> AsyncIterator[str]:
55+
# Whoever can reach this agent's ACP writes the prompt, so the CLI gets read-only
56+
# tools, denies anything that would need approval (such as reads outside its
57+
# working directory), and ignores the developer's own Claude Code settings,
58+
# hooks, plugins and MCP servers. Widen this only if every caller is trusted.
59+
CLAUDE_POLICY_ARGS = (
60+
"--tools", "Read,Glob,Grep",
61+
"--permission-mode", "dontAsk",
62+
"--setting-sources", "",
63+
"--strict-mcp-config",
64+
)
65+
# The only variables the CLI sees; USER lets it find a macOS keychain login.
66+
CLAUDE_ENV_KEYS = ("ANTHROPIC_API_KEY", "PATH", "HOME", "USER")
67+
CLAUDE_WORKDIR_ROOT = Path(tempfile.gettempdir()) / "agentex-claude-code"
68+
69+
70+
def _claude_workdir(task_id: str) -> Path:
71+
"""Per-task scratch directory for the CLI, outside this project (which holds .env).
72+
73+
Stable across turns: the CLI files sessions by working directory, so
74+
``-r <session_id>`` only finds the previous turn from the same directory.
75+
"""
76+
# Hashed because the task id comes from the request and must not steer the path.
77+
workdir = CLAUDE_WORKDIR_ROOT / hashlib.sha256(task_id.encode()).hexdigest()[:32]
78+
workdir.mkdir(mode=0o700, parents=True, exist_ok=True)
79+
return workdir
80+
81+
82+
async def _spawn_claude(prompt: str, task_id: str, session_id: str | None = None) -> AsyncIterator[str]:
5283
"""Spawn ``claude -p --output-format stream-json`` locally and yield stdout lines.
5384
5485
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
6394
"--output-format",
6495
"stream-json",
6596
"--verbose",
97+
*CLAUDE_POLICY_ARGS,
6698
]
6799
if session_id:
68100
cmd.extend(["-r", session_id])
69101

70102
proc = await asyncio.create_subprocess_exec(
71103
*cmd,
104+
cwd=_claude_workdir(task_id),
105+
env={key: os.environ[key] for key in CLAUDE_ENV_KEYS if key in os.environ},
72106
stdin=asyncio.subprocess.PIPE,
73107
stdout=asyncio.subprocess.PIPE,
74108
stderr=asyncio.subprocess.PIPE,
@@ -133,7 +167,7 @@ async def run_claude_code_turn(params: RunClaudeCodeTurnParams) -> dict[str, Any
133167
trace_id=params.trace_id,
134168
parent_span_id=params.parent_span_id,
135169
)
136-
turn = ClaudeCodeTurn(_spawn_claude(params.prompt, session_id=params.session_id))
170+
turn = ClaudeCodeTurn(_spawn_claude(params.prompt, params.task_id, session_id=params.session_id))
137171
result = await emitter.auto_send_turn(turn, created_at=params.created_at)
138172

139173
return RunClaudeCodeTurnResult(final_text=result.final_text, session_id=turn.session_id).model_dump()

‎src/agentex/lib/cli/commands/agents.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
validate_namespace,
2323
check_and_switch_cluster_context,
2424
)
25+
from agentex.lib.cli.handlers.run_handlers import DEFAULT_ACP_HOST
2526
from agentex.lib.sdk.config.agent_manifest import load_agent_manifest
2627
from agentex.lib.cli.handlers.agent_handlers import (
2728
run_agent,
@@ -277,6 +278,13 @@ def run(
277278
debug_acp: bool = typer.Option(False, help="Enable debug mode for ACP server only"),
278279
debug_port: int = typer.Option(5678, help="Port for remote debugging (worker uses this, ACP uses port+1)"),
279280
wait_for_debugger: bool = typer.Option(False, help="Wait for debugger to attach before starting"),
281+
host: str = typer.Option(
282+
DEFAULT_ACP_HOST,
283+
envvar="AGENTEX_AGENT_HOST",
284+
help="Interface the local ACP server listens on. It has no authentication, so use 0.0.0.0 only when "
285+
"something off this machine must reach it, such as an agentex server in a Linux container that "
286+
"dials host.docker.internal through host-gateway.",
287+
),
280288
) -> None:
281289
"""
282290
Run an agent locally from the given manifest.
@@ -326,7 +334,7 @@ def run(
326334
console.print("[yellow]⏳ Processes will wait for debugger attachment[/yellow]")
327335

328336
try:
329-
run_agent(manifest_path=manifest, debug_config=debug_config)
337+
run_agent(manifest_path=manifest, debug_config=debug_config, host=host)
330338
except Exception as e:
331339
typer.echo(f"Error running agent: {str(e)}", err=True)
332340
logger.exception("Error running agent")

‎src/agentex/lib/cli/debug/debug_handlers.py‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,10 +72,11 @@ async def start_temporal_worker_debug(
7272

7373

7474
async def start_acp_server_debug(
75-
acp_path: Path,
76-
port: int,
75+
acp_path: Path,
76+
port: int,
7777
env: Dict[str, str],
78-
debug_config: DebugConfig
78+
debug_config: DebugConfig,
79+
host: str = "127.0.0.1",
7980
):
8081
"""Start ACP server with debug support"""
8182

@@ -103,7 +104,7 @@ async def start_acp_server_debug(
103104
"--port",
104105
str(port),
105106
"--host",
106-
"0.0.0.0",
107+
host,
107108
# Note: No --reload flag when debugging
108109
]
109110

0 commit comments

Comments
 (0)