Skip to content

fix(cli): sandbox the claude-code harness and run the local ACP on loopback - #554

Closed
stephen-wang24 wants to merge 2 commits into
mainfrom
stephen-wang/vuln-3136-claude-code-harness-sandbox
Closed

stephen-wang24 wants to merge 2 commits into
mainfrom
stephen-wang/vuln-3136-claude-code-harness-sandbox

Conversation

@stephen-wang24

@stephen-wang24 stephen-wang24 commented Oct 26, 2026 •

Copy link
Copy Markdown
Contributor

The three Claude Code harness templates (sync-claude-code, default-claude-code, temporal-claude-code) and their three claude_code tutorials now run the claude CLI in a per-task scratch directory, with a short environment allowlist and an explicit tool policy. agentex agents run now listens on 127.0.0.1 by default.

What changed

  • Working directory and environment. The CLI runs in $TMPDIR/agentex-claude-code/<sha256(task id)> instead of the agent's working directory, which under agentex agents run is the project folder with .env and manifest.yaml. The directory is stable per task, so Temporal's -r <session_id> resume still finds the previous turn, and its root must be a private directory owned by the current user (an lstat check on POSIX). The CLI's environment is only ANTHROPIC_API_KEY, PATH, HOME and USER (USER lets it find a macOS keychain login).
  • Tool policy. --tools Read,Glob,Grep --permission-mode dontAsk --setting-sources "" --strict-mcp-config: the model gets read-only tools, anything that would need approval (such as a read outside its working directory) is refused, and the developer's own Claude Code settings, hooks, plugins and MCP servers are not loaded. The template and tutorial Dockerfiles pin @anthropic-ai/[email protected], the version the tutorials CI installs; all four flags exist in 2.1.142 and in 2.1.311.
  • Listen address. agentex agents run (also with --debug or --debug-acp) 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: its AgentEx server runs in Docker on Linux and reaches agents at host.docker.internal through host-gateway, which maps to the Docker bridge, not loopback. The override is documented in the CLI help, the root README and the three template READMEs.

Before / After

Behavior Before After
Files the CLI reads without asking, under agentex agents run The agent's project folder, including .env and manifest.yaml Only its own per-task scratch directory, which starts empty
Environment the CLI gets Everything the agent process has, including values loaded from .env and the manifest's env: ANTHROPIC_API_KEY, PATH, HOME, USER
Tools the model can call (this Mac) 50 to 131, including Bash, Write, WebFetch and the developer's MCP tools Read, Glob, Grep
The developer's own Claude Code hooks, plugins and MCP servers Loaded Not loaded; only organisation-managed settings apply
Claude Code CLI in the images Whatever npm resolves at build time 2.1.142
agentex agents run listens on All interfaces (*:<port>) 127.0.0.1:<port>, unless --host or AGENTEX_AGENT_HOST says otherwise

Proof

Asking the agent for its project files. On main the agent returns the test values from both .env and manifest.yaml; on this branch it returns neither. Each row is one message/send request sent straight to the agent's port, to the sync template started with agentex agents run from a terminal-like environment with the local claude signed in. The project's .env and its manifest.yaml env: block each hold one test value.

What was asked (paraphrased) main This branch
Print .env and manifest.yaml from your working directory Prints both files, test values included Finds neither: its working directory is the empty scratch directory
Read <project>/.env by its full path Prints the file, test value included Permission to use Read has been denied because Claude Code is running in don't ask mode
Run cat <project>/.env through Bash Doesn't run: Bash needs approval in -p mode No Bash tool exists; the Read it tried instead was refused
Search <project> with Grep for the test value No result: no Grep tool, and its grep through Bash needed approval Permission to use Grep has been denied because Claude Code is running in don't ask mode
What is 17 + 25? 42 42

Tool steps for the first two rows, from the agent's response stream (the .env line is shown as a placeholder):

main    call   Read {"file_path": ".../main/cc_sync_proof/.env"}
main    result "1\t<the .env line with its test value>\n2\t"
branch  call   Read {"file_path": ".../T/agentex-claude-code/4973acd6b272dbbb7e97cf57b35594a4/.env"}
branch  result "File does not exist. Note: your current working directory is .../T/agentex-claude-code/4973acd6b272dbbb7e97cf57b35594a4."
branch  call   Read {"file_path": ".../branch/cc_sync_proof/.env"}
branch  result "Permission to use Read has been denied because Claude Code is running in don't ask mode. ..."

Listen address (lsof -iTCP:<port> -sTCP:LISTEN while agentex agents run is up; the two rows are uvicorn's reloader and worker):

### main: agentex agents run
python3.1 10230 IPv4 TCP *:18001 (LISTEN)
python3.1 10277 IPv4 TCP *:18001 (LISTEN)
### branch-default: agentex agents run
python3.1 10361 IPv4 TCP 127.0.0.1:18002 (LISTEN)
python3.1 10391 IPv4 TCP 127.0.0.1:18002 (LISTEN)
### branch-flag: agentex agents run --host 0.0.0.0
python3.1 10468 IPv4 TCP *:18002 (LISTEN)
python3.1 10494 IPv4 TCP *:18002 (LISTEN)
### branch-envvar: AGENTEX_AGENT_HOST=0.0.0.0 agentex agents run
python3.1 10575 IPv4 TCP *:18002 (LISTEN)
python3.1 10606 IPv4 TCP *:18002 (LISTEN)

What the CLI loads. Counted from the CLI's own system/init stream-json message, with a terminal-like environment and this Mac's real ~/.claude config:

CLI Spawned as on main Spawned as on this branch
2.1.300 4 hooks run, 6 plugins, 2 MCP servers, 50 tools, mode default 1 hook (organisation-managed), 3 built-in plugins, 0 MCP servers, 3 tools, mode dontAsk
2.1.142 4 hooks run, 2 plugins, 2 MCP servers (both connected), 131 tools, mode default 1 hook (organisation-managed), 0 plugins, 0 MCP servers, 3 tools, mode dontAsk

Normal prompts and resume. The sync template is the last row of the first table. For async and Temporal, the full loop needs the AgentEx server, Redis and Temporal, which this Mac could not run, so each rendered template's real _spawn_claude ran from its project folder:

async    (2.1.311)  task-async-1                                   tools [Glob, Grep, Read], dontAsk -> "42"
temporal (2.1.142)  turn 1, task-temporal-142                      -> "noted."    (code word HERON-17)
temporal (2.1.142)  turn 2, same task, -r <session_id>             -> "HERON-17"
temporal (2.1.142)  -r <session_id> from another task's directory  -> "No conversation found with session ID: ..."
temporal (2.1.311)  turn 2, same task, -r <session_id>             -> "PELICAN-42"

The fourth line is why the scratch directory is stable per task: the pinned CLI resumes a session only from the directory it started in.

End to end in CI. The tutorials CI on this PR ran the three claude_code tutorials live (CLI 2.1.142, real key) against the AgentEx server in Docker on Linux, reaching the agents through AGENTEX_AGENT_HOST=0.0.0.0. TestClaudeCodeLive passed for 060_claude_code (sync), 130_claude_code (async) and 140_claude_code (Temporal).

Local dev loop on macOS. The AgentEx server (scale-agentex main at 4ccf8b8, built from source for arm64) ran in Docker on Rancher Desktop. Inside its container host.docker.internal resolves to 192.168.5.2, the VM's gateway to the Mac. The sync template agent from this branch registered http://host.docker.internal:18002, and a message sent through the server came back with the agent's answer, with the default bind and with the override:

### branch-default: listening on 127.0.0.1:18002
with acp_url: http://host.docker.internal:18002
errors: none
answer: 42

### branch-override (AGENTEX_AGENT_HOST=0.0.0.0): listening on *:18002
with acp_url: http://host.docker.internal:18002
errors: none
answer: 42

For the async and Temporal templates, the server's calls reached the agents on 127.0.0.1 as well (2 and 3 POST /api 200s in the agents' logs), but each agent's own call back to this locally built server failed before the Claude CLI ran, in code this PR does not touch; their replies are shown by the tutorials CI above. Linux host-gateway setups dial the Docker bridge address instead, which a loopback bind does not serve; they need the override, as in the tutorials CI.

Supporting checks. New tests: tests/lib/cli/test_claude_code_spawn_policy.py renders each claude-code template and runs its real _spawn_claude in a child interpreter against a stub claude on PATH. It asserts the argv policy, a scratch directory outside the project (stable per task, not steerable by the task id, refused when its root is a link or world-writable), the four-variable environment, and -r <session_id> resuming in the same directory. tests/lib/cli/test_agents_run_host.py asserts the --host default, flag and environment variable, and the host passed to uvicorn in normal and debug mode.

$ uv run pytest tests/lib/cli/test_claude_code_spawn_policy.py tests/lib/cli/test_agents_run_host.py tests/lib/cli/test_init_templates.py tests/lib/cli/test_run_handlers_streaming.py -n 0
45 passed in 57.83s

$ ./scripts/test        # CI's test job, both Python versions (at d9ccd2da)
==> Running tests for Python >=3.12.0,<3.13
1772 passed, 1622 skipped in 49.57s
==> Running tests for Python >=3.13.0,<3.14
1772 passed, 1622 skipped in 49.98s

$ ./scripts/lint        # ruff, pyright, import check
All checks passed!
0 errors, 0 warnings, 0 informations

CI on d38f5ce: all 49 checks pass. The test job (both Python versions) shows 1778 passed, 1622 skipped; its first attempt hit one failure in tests/lib/test_build_provenance.py::test_capture_clean_tree (a temporary .git/objects/maintenance.lock vanished mid-hash, in code this PR does not touch; it passes locally 5/5) and passed on re-run.

Not verified yet

  • Async and Temporal replies through the local Docker stack on macOS (see the local dev loop above). Docker Desktop was not tested.

Risk to agentex

  • Setups where the AgentEx server reaches local agents from a Linux container through host-gateway, or from another machine, must now pass --host 0.0.0.0 or set AGENTEX_AGENT_HOST=0.0.0.0. Rancher Desktop on macOS (VM-to-host hop tested above) and the server's no-Docker mode (which dials 127.0.0.1) keep working without it.
  • Claude in the three templates can now read only its own scratch directory. A developer who wants it to work on project files must widen CLAUDE_POLICY_ARGS on purpose, for example with --add-dir.
  • A setup that routes the CLI through a gateway or proxy (ANTHROPIC_BASE_URL, HTTPS_PROXY) must add that variable to CLAUDE_ENV_KEYS; the template READMEs say so.
  • Overlaps open PR fix(templates): read long stream-json lines and bound Claude Code shutdown #542 (same _spawn_claude functions; its new test calls the old one-argument signature). Whichever merges second needs a small rebase.

🤖 Generated with Claude Code

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; the previous scratch-root issue is fixed.

Summary

The PR restricts Claude's tools, environment, and working directory, and makes local ACP servers listen on loopback by default.

  • Claude Code runs each task in a scratch folder with limited tools and environment.
  • Locally run agents listen on loopback unless you choose another host.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  A[Local caller] --> B[ACP on loopback by default]
  B --> C[Check private scratch root]
  C --> D[Stable task directory]
  D --> E[Claude with read-only tools and limited environment]
Loading

Reviews (2) · Last reviewed commit: "fix(cli): refuse a claude-code scratch r..." · Reviewed by Greptile

…opback

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/[email protected] 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 <[email protected]>
@stephen-wang24

Copy link
Copy Markdown
Contributor Author

@greptileai

Comment thread src/agentex/lib/cli/templates/sync-claude-code/project/acp.py.j2
…trol

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 <[email protected]>
@stephen-wang24

Copy link
Copy Markdown
Contributor Author

@greptileai

@stephen-wang24
stephen-wang24 deleted the stephen-wang/vuln-3136-claude-code-harness-sandbox branch October 27, 2026 15:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant