Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,8 @@ CONTEXT7_API_KEY=your_context7_api_key_here
# Perplexity - Required for the Perplexity MCP server
# Get your API key from https://www.perplexity.ai/settings/api
PERPLEXITY_API_KEY=your_perplexity_api_key_here

# Remote Control (optional, OFF by default). Set to 1 to add --remote-control to
# the entrypoint. NOTE: it needs a full-scope login token (`claude auth login`);
# the inference-only CLAUDE_CODE_OAUTH_TOKEN above cannot drive Remote Control.
# CLAUDE_REMOTE_CONTROL=1
12 changes: 9 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,9 @@ pnpm 11 vs Node 20 mismatch was caught before the base was bumped to Node 22).

**Runtime variables** (set when running container):
- `CLAUDE_CODE_OAUTH_TOKEN` - OAuth token for Claude Code authentication (required)
- `CLAUDE_REMOTE_CONTROL` - set to `1` to add `--remote-control` to the entrypoint (off by default).
Requires a full-scope login token (`claude auth login`); the inference-only `CLAUDE_CODE_OAUTH_TOKEN`
cannot drive Remote Control, so this is a no-op with it.
- `MCP_TIMEOUT` - MCP server connection timeout in milliseconds (default: `10000` = 10 seconds)
- All variables from `.env` file are automatically passed to the container

Expand All @@ -236,8 +239,10 @@ pnpm 11 vs Node 20 mismatch was caught before the base was bumped to Node 22).

1. **Run from your project**: `cd /path/to/repo && ./run_claude.sh`. The current directory is mounted
**read-write** at `/workspace`; the container runs as your host user (`--user $(id -u):$(id -g)`).
2. **Entrypoint**: `claude --dangerously-skip-permissions --remote-control`; the entrypoint first
copies the baked agent state from `/home/claude` into the writable tmpfs HOME.
2. **Entrypoint**: `claude --dangerously-skip-permissions`; the entrypoint first
copies the baked agent state from `/home/claude` into the writable tmpfs HOME. Remote Control is
opt-in: `--remote-control` is added only when `CLAUDE_REMOTE_CONTROL=1` (it needs a full-scope
login token; the inference-only `CLAUDE_CODE_OAUTH_TOKEN` cannot drive it).
3. **Autonomous agent**: Claude edits/commits the project directly in `/workspace`. For `git push`,
set `DEPLOY_KEY=/path/to/repo_deploy_key` (scoped, read-only mounted). Commit identity comes from
your host `git config` (passed as env).
Expand Down Expand Up @@ -379,7 +384,8 @@ Inside the debug shell, you can run diagnostics manually:
- `tools/package.json` - Pinned npm CLI toolchain (claude-code, openspec, codegraph, caveman-shrink, MCP servers, dev tools) — exact versions, single source of truth
- `tools/package-lock.json` - Lockfile (sha512 integrity) for the toolchain; installed via `npm ci`. Regenerate inside node:22 after editing package.json
- `claude-config.json` - Claude Code configuration with all permissions
- `settings.local.json` - Local Claude settings
- `settings.local.json` - Local Claude settings; also wires the `statusLine` to `/usr/local/bin/claude-statusline.sh`
- `statusline-command.sh` - Claude Code statusLine script (compact line: dir, git branch/dirty, model, duration, context %, 5h/7d rate limits); baked to `/usr/local/bin/claude-statusline.sh` (fixed, HOME-independent path). Deps (jq, git, awk, date, grep) are all present in the image
- `mcp-servers.json` - Base MCP server configurations (always installed)
- `mcp-servers-optional.json` - Optional MCP servers (require API keys)
- `install-mcp-servers.sh` - MCP installation script with variable substitution
Expand Down
26 changes: 20 additions & 6 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,11 @@ COPY --chown=${USER_NAME}:${USER_NAME} claude-config.json /home/${USER_NAME}/.cl
# COPY --chown creates the .claude dir owned by the user (a root `mkdir` here would
# leave it root-owned and break the later openspec/rtk init under USER claude).
COPY --chown=${USER_NAME}:${USER_NAME} settings.local.json /home/${USER_NAME}/.claude/settings.local.json
# Statusline command for Claude Code (compact line: dir, git, model, context, rate
# limits). Installed at a fixed, HOME-independent path so settings.local.json's
# statusLine.command works regardless of the runtime HOME relocation. Deps (jq, git,
# awk, date, grep) are all present in the image.
COPY --chmod=0755 statusline-command.sh /usr/local/bin/claude-statusline.sh

# Switch to non-root user for the agent-init layers (git config, RTK, caveman).
USER ${USER_NAME}
Expand Down Expand Up @@ -242,11 +247,14 @@ RUN rtk init -g --auto-patch
RUN npx -y github:JuliusBrussee/caveman#v1.9.0 --non-interactive --only claude --no-mcp-shrink

# Create simple startup script for runtime.
# --remote-control: start with Remote Control enabled by default (per project
# requirement) so the in-container agent can be driven remotely.
# --remote-control: OPT-IN via CLAUDE_REMOTE_CONTROL=1 (off by default). Remote
# Control requires a full-scope login token (`claude auth login`); the
# long-lived CLAUDE_CODE_OAUTH_TOKEN / `claude setup-token` this image uses is
# inference-only, so RC stays disabled with it. The flag is added only when the
# env var is set, so it is never a dead default. See SECURITY.md.
# --dangerously-skip-permissions: the container boundary is the perimeter (see
# SECURITY.md). NOTE: Remote Control opens an outbound control channel — covered
# in the threat model.
# SECURITY.md). NOTE: when enabled, Remote Control opens an outbound control
# channel — covered in the threat model.
RUN echo '#!/bin/bash\n\
set -e\n\
# Runtime starts with --user $(id -u):$(id -g) to match host ownership of the rw\n\
Expand All @@ -258,8 +266,14 @@ if [ "$HOME" != "/home/claude" ] && [ ! -e "$HOME/.claude.json" ]; then\n\
cp -a /home/claude/. "$HOME/" 2>/dev/null || true\n\
fi\n\
cd /workspace 2>/dev/null || cd "$HOME"\n\
echo "Starting Claude Code (Remote Control enabled) in $(pwd)..."\n\
exec claude --dangerously-skip-permissions --remote-control "$@"\n\
RC_ARGS=""\n\
if [ "${CLAUDE_REMOTE_CONTROL:-0}" = "1" ]; then\n\
RC_ARGS="--remote-control"\n\
echo "Starting Claude Code (Remote Control requested — needs a full-scope login token; see SECURITY.md) in $(pwd)..."\n\
else\n\
echo "Starting Claude Code in $(pwd)..."\n\
fi\n\
exec claude --dangerously-skip-permissions $RC_ARGS "$@"\n\
' > /home/${USER_NAME}/start-claude.sh \
&& chmod +x /home/${USER_NAME}/start-claude.sh

Expand Down
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,8 +117,11 @@ A single mode. The wrapper scripts (`run_claude.sh`, `debug-shell.sh`) apply har
- Footgun guards: the scripts refuse `--privileged`, `docker.sock`, `--pid=host`, `--network=host`,
`--cap-add`, or running as host root

The entrypoint runs `claude --dangerously-skip-permissions --remote-control` — the container
boundary is the perimeter, and Remote Control lets you drive the in-container agent remotely.
The entrypoint runs `claude --dangerously-skip-permissions` — the container boundary is the
perimeter. Remote Control (driving the in-container agent remotely) is **opt-in**: set
`CLAUDE_REMOTE_CONTROL=1` to add `--remote-control`. Note it needs a full-scope login token
(`claude auth login`); the `CLAUDE_CODE_OAUTH_TOKEN` this image uses is inference-only, so Remote
Control stays disabled with it.

> ⚠️ **On a host where the Docker daemon runs as root, `docker run` is equivalent to host root.**
> The wrapper scripts and their guards protect against *accidental* misconfiguration, **not** a
Expand Down
10 changes: 6 additions & 4 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,12 +54,14 @@ may be root. Consequently:

## Known limitations (by design)

- **The image ENTRYPOINT is permissive** (`--dangerously-skip-permissions --remote-control`). The
- **The image ENTRYPOINT is permissive** (`--dangerously-skip-permissions`). The
container boundary is the perimeter; in-app permission prompts are disabled. A bare `docker run`
without a wrapper still has no host-level hardening. Always use the wrapper scripts.
- **Remote Control opens an outbound control channel.** The entrypoint enables `--remote-control` by
default; this is an outbound connection to the Remote Control service. Disable it (override the
entrypoint / command) if your environment forbids that channel.
- **Remote Control is opt-in and off by default.** Set `CLAUDE_REMOTE_CONTROL=1` to add
`--remote-control`, which opens an outbound connection to the Remote Control service. It also
requires a **full-scope login token** (`claude auth login`): the long-lived `CLAUDE_CODE_OAUTH_TOKEN`
/ `claude setup-token` this image normally uses is inference-only, so Remote Control stays disabled
with it even if the flag is set. Leave the env var unset if your environment forbids that channel.
- **Outbound network is allowed.** Bridge networking blocks the host network but not the internet.
`context7` and `perplexity` MCP servers transmit data (including code context) to third parties.
On a root host this is an exfiltration channel under prompt injection — restrict egress at the
Expand Down
5 changes: 5 additions & 0 deletions settings.local.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,5 +3,10 @@
"allow": [],
"deny": [],
"ask": []
},
"statusLine": {
"type": "command",
"command": "/usr/local/bin/claude-statusline.sh",
"padding": 0
}
}
131 changes: 131 additions & 0 deletions statusline-command.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
#!/usr/bin/env bash
# Claude Code statusLine command — compact with icons
# Reads JSON from stdin, outputs a single status line

input=$(cat)

# --- Parse all fields with a single jq call ---
eval "$(echo "$input" | jq -r '
@sh "cwd=\(.workspace.current_dir // .cwd // "")",
@sh "project_dir=\(.workspace.project_dir // .cwd // ".")",
@sh "model=\(.model.display_name // "")",
@sh "remaining=\(.context_window.remaining_percentage // "")",
@sh "total_in=\(.context_window.total_input_tokens // 0)",
@sh "total_out=\(.context_window.total_output_tokens // 0)",
@sh "duration_ms=\(.cost.total_duration_ms // 0)",
@sh "five_hour_used=\(.rate_limits.five_hour.used_percentage // "")",
@sh "five_hour_reset=\(.rate_limits.five_hour.resets_at // "")",
@sh "seven_day_used=\(.rate_limits.seven_day.used_percentage // "")"
' 2>/dev/null)"

# --- Directory (Starship substitution) ---
home="$HOME"
cwd="${cwd/#$home/\~}"
cwd="${cwd/#\~\/WebstormProjects\//}"
IFS='/' read -ra parts <<< "$cwd"
count=${#parts[@]}
if [ "$count" -gt 3 ]; then
cwd="${parts[$((count-3))]}/${parts[$((count-2))]}/${parts[$((count-1))]}"
fi

# --- Git branch + dirty ---
branch=$(GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" symbolic-ref --short HEAD 2>/dev/null || \
GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" rev-parse --short HEAD 2>/dev/null)
if [ -n "$branch" ]; then
dirty=$(GIT_OPTIONAL_LOCKS=0 git -C "$project_dir" status --porcelain 2>/dev/null)
staged=$(echo "$dirty" | grep -c '^[MARCDT]' 2>/dev/null || true)
modified=$(echo "$dirty" | grep -c '^.[MARCDT?]' 2>/dev/null || true)
fi

# --- Format tokens (19734 → 19.7k) ---
total_tok=$((total_in + total_out))
if [ "$total_tok" -ge 1000000 ]; then
tok_str="$(awk "BEGIN{printf \"%.1f\", $total_tok/1000000}")M"
elif [ "$total_tok" -ge 1000 ]; then
tok_str="$(awk "BEGIN{printf \"%.1f\", $total_tok/1000}")k"
else
tok_str="${total_tok}"
fi

# --- Format duration ---
duration_s=$((duration_ms / 1000))
dur_h=$((duration_s / 3600))
dur_m=$(( (duration_s % 3600) / 60 ))
if [ "$dur_h" -gt 0 ]; then
dur_str="${dur_h}h${dur_m}m"
else
dur_str="${dur_m}m"
fi

# --- Colors ---
cyan='\033[0;36m'
purple='\033[0;35m'
blue='\033[0;34m'
yellow='\033[0;33m'
green='\033[0;32m'
red='\033[0;31m'
dim='\033[2m'
rst='\033[0m'

# === Assemble output ===

# Directory (cyan)
printf "${cyan} %s${rst}" "$cwd"

# Git branch + dirty (purple + yellow)
if [ -n "$branch" ]; then
printf " ${purple} %s${rst}" "$branch"
if [ "$staged" -gt 0 ] 2>/dev/null; then
printf " ${green}+%d${rst}" "$staged"
fi
if [ "$modified" -gt 0 ] 2>/dev/null; then
printf " ${yellow}~%d${rst}" "$modified"
fi
fi

# Model (blue)
if [ -n "$model" ]; then
printf " ${blue} %s${rst}" "$model"
fi

# Duration (before context)
printf " ${dim}󱑂 ${dur_str}${rst}"

# Context remaining + session tokens (yellow)
if [ -n "$remaining" ]; then
remaining_int=$(printf '%.0f' "$remaining")
printf " ${yellow}󰄰 %d%%${rst}" "$remaining_int"
if [ "$total_tok" -gt 0 ]; then
printf " ${dim}· %s${rst}" "$tok_str"
fi
fi

# Rate limit — 5h (green)
if [ -n "$five_hour_used" ]; then
used_int=$(printf '%.0f' "$five_hour_used")
rem_pct=$((100 - used_int))
printf " ${green}󱐋 5h - %d%%" "$rem_pct"
if [ -n "$five_hour_reset" ]; then
now=$(date +%s)
diff_s=$((five_hour_reset - now))
if [ "$diff_s" -gt 0 ]; then
diff_h=$((diff_s / 3600))
diff_m=$(( (diff_s % 3600) / 60 ))
if [ "$diff_h" -gt 0 ]; then
printf " ${dim}(%dh%dm)${green}" "$diff_h" "$diff_m"
else
printf " ${dim}(%dm)${green}" "$diff_m"
fi
fi
fi
printf "${rst}"
fi

# Rate limit — 7d (green)
if [ -n "$seven_day_used" ]; then
used7_int=$(printf '%.0f' "$seven_day_used")
rem7_pct=$((100 - used7_int))
printf " ${green}󰃭 7d - %d%%${rst}" "$rem7_pct"
fi

printf '\n'
Loading