diff --git a/.env.example b/.env.example index 9586b7f..b4217a1 100644 --- a/.env.example +++ b/.env.example @@ -14,6 +14,11 @@ CONTEXT7_API_KEY=your_context7_api_key_here # Get your API key from https://www.perplexity.ai/settings/api PERPLEXITY_API_KEY=your_perplexity_api_key_here +# Permission bypass (optional, OFF by default). Default is auto mode (autonomous + +# background classifier). Set to 1 to add --dangerously-skip-permissions instead +# (full bypass, no in-app safety checks) — for isolated/throwaway containers only. +# CLAUDE_BYPASS_PERMISSIONS=1 + # 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. diff --git a/CLAUDE.md b/CLAUDE.md index aedcef9..65a4562 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,8 @@ The container implements defense-in-depth: Claude Code configuration is pre-configured in the container: - `claude-config.json` - Main Claude configuration with all permissions enabled -- `settings.local.json` - Local settings copied to `~/.claude/` and `/workspace/.claude/` +- `settings.local.json` - Local settings copied to `~/.claude/settings.local.json` (statusLine + permissions) +- `settings.json` - User settings copied to `~/.claude/settings.json`: `permissions.defaultMode: "auto"` + `advisorModel: "opus"` (auto mode must be in user-home, not project scope) - All permissions are auto-accepted for jailfree operation mode ## Common Commands @@ -216,6 +217,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_BYPASS_PERMISSIONS` - set to `1` to add `--dangerously-skip-permissions` to the entrypoint + (off by default; default is auto mode). Full bypass, no in-app safety checks — for isolated/throwaway + containers only. - `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. @@ -228,21 +232,32 @@ pnpm 11 vs Node 20 mismatch was caught before the base was bumped to Node 22). - `CODEGRAPH_NO_DOWNLOAD=1` (forbids CodeGraph's runtime binary download from GitHub Releases; binary must come from the npm registry) - `NODE_ENV=production`, plus security limits (`RLIMIT_CORE=0`, `RLIMIT_NOFILE=1024`, `YAMA_PTRACE_SCOPE=1`) -**Pre-configured behavior** (`claude-config.json`, copied to `~/.claude.json`): -- Full permission bypass for `/workspace`: `dangerouslySkipPermissions`, `autoAcceptPermissions`, - `defaultMode: acceptEdits`, all tools + `mcp__*` allowed +**Pre-configured behavior:** +- **Permission mode = `auto`** (set in `~/.claude/settings.json`, NOT `claude-config.json`). `auto` + must live in user-home `settings.json` — Claude Code (v2.1.142+) ignores `defaultMode: "auto"` from + project-scope settings. `claude-config.json` (`~/.claude.json`) is deliberately stripped of all + mode/auto-accept-forcing keys (`dangerouslySkipPermissions`, `autoAcceptPermissions`, + `defaultPermissionMode`, project `permissions.defaultMode`, `autoAccept*`) so settings.json is the + single source of mode. Trust/onboarding keys (`hasTrustDialogAccepted`, `hasCompletedOnboarding`, + `autoTrustNewProjects`, `suppressTrustPrompts`, `bypassPermissionsModeAccepted`) are kept to avoid + first-run dialogs in headless. +- **`advisorModel: "opus"`** (in `settings.json`) — Claude consults Opus at decision points (Anthropic + API only). No-op if the main model outranks Opus (e.g. Fable). +- `claude-config.json` still pre-approves tools for `/workspace` (`allow` rules + `mcp__*`); note auto + mode drops blanket `Bash(*)`/`Agent` rules at runtime (the classifier takes over). - `alwaysThinkingEnabled: true`, `autoUpdates: false` - The `typescript-lsp@claude-plugins-official` plugin is enabled (works with `ENABLE_LSP_TOOL=1`) -- The OAuth account is hard-coded in this file — replace it if using a different account +- The OAuth account is hard-coded in `claude-config.json` — replace it if using a different account ## Development Workflow (single read-write mode) 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`; 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). +2. **Entrypoint**: `claude` (auto mode via settings.json); the entrypoint first copies the baked + agent state from `/home/claude` into the writable tmpfs HOME. Two opt-in flags, each added only + when its env var is set: `CLAUDE_BYPASS_PERMISSIONS=1` → `--dangerously-skip-permissions` (full + bypass for isolated containers); `CLAUDE_REMOTE_CONTROL=1` → `--remote-control` (needs a + full-scope `claude auth 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). @@ -385,6 +400,7 @@ Inside the debug shell, you can run diagnostics manually: - `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; also wires the `statusLine` to `/usr/local/bin/claude-statusline.sh` +- `settings.json` - User Claude settings baked to `~/.claude/settings.json`: `permissions.defaultMode: "auto"` + `advisorModel: "opus"` - `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) diff --git a/Dockerfile b/Dockerfile index e5b0e81..ab1b738 100644 --- a/Dockerfile +++ b/Dockerfile @@ -172,6 +172,10 @@ 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 +# User-level settings.json: permission defaultMode "auto" + advisorModel. MUST be +# user-home (~/.claude/settings.json) — Claude Code (v2.1.142+) ignores defaultMode +# "auto" from project-scope settings, so this never goes to /workspace/.claude. +COPY --chown=${USER_NAME}:${USER_NAME} settings.json /home/${USER_NAME}/.claude/settings.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, @@ -247,14 +251,18 @@ 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. +# Permission mode: default is `auto` (set in ~/.claude/settings.json) — autonomous +# with a background classifier, NOT the old `--dangerously-skip-permissions` +# bypass. Auto mode engages only on the Anthropic API with a supported model; if +# unavailable it silently falls back to `default` (prompts). See SECURITY.md. +# --dangerously-skip-permissions: OPT-IN via CLAUDE_BYPASS_PERMISSIONS=1 (off by +# default). Re-adds the full bypass for isolated/throwaway containers where you +# accept no in-app safety checks. The flag is added only when the env var is set, +# so it is never a dead default. # --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: when enabled, Remote Control opens an outbound control -# channel — covered in the threat model. +# inference-only, so RC stays disabled with it. RUN echo '#!/bin/bash\n\ set -e\n\ # Runtime starts with --user $(id -u):$(id -g) to match host ownership of the rw\n\ @@ -266,14 +274,18 @@ 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\ -RC_ARGS=""\n\ +EXTRA_ARGS=""\n\ +mode_msg="permission mode: auto (default; falls back to default mode if auto is unavailable)"\n\ +if [ "${CLAUDE_BYPASS_PERMISSIONS:-0}" = "1" ]; then\n\ + EXTRA_ARGS="$EXTRA_ARGS --dangerously-skip-permissions"\n\ + mode_msg="permission mode: bypass (CLAUDE_BYPASS_PERMISSIONS=1 — no in-app safety checks)"\n\ +fi\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\ + EXTRA_ARGS="$EXTRA_ARGS --remote-control"\n\ + mode_msg="$mode_msg; Remote Control requested (needs a full-scope login token; see SECURITY.md)"\n\ fi\n\ -exec claude --dangerously-skip-permissions $RC_ARGS "$@"\n\ +echo "Starting Claude Code in $(pwd) — $mode_msg..."\n\ +exec claude $EXTRA_ARGS "$@"\n\ ' > /home/${USER_NAME}/start-claude.sh \ && chmod +x /home/${USER_NAME}/start-claude.sh diff --git a/README.md b/README.md index 4f2a456..58caf0b 100644 --- a/README.md +++ b/README.md @@ -117,11 +117,22 @@ 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` — 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. +The entrypoint runs `claude` in **auto mode** (`permissions.defaultMode: "auto"` in +`~/.claude/settings.json`) — autonomous, but with a background classifier that blocks dangerous +actions (prompt-injection-driven commands, `curl | bash`, force-push, pushing to `main`, prod +deploys). The OS-level container boundary is still the perimeter. Auto mode engages on the Anthropic +API with a supported model; if it's unavailable for your account it **silently falls back to +`default`** (prompts on each action) — confirm the status bar shows `auto` on first run. + +Two opt-in env vars (off by default, each added to the launch only when set): +- `CLAUDE_BYPASS_PERMISSIONS=1` — re-adds `--dangerously-skip-permissions` (full bypass, no in-app + safety checks) for isolated/throwaway containers. +- `CLAUDE_REMOTE_CONTROL=1` — adds `--remote-control` (needs a full-scope `claude auth login` token; + the inference-only `CLAUDE_CODE_OAUTH_TOKEN` cannot drive it). + +A stronger **advisor** model (`advisorModel: "opus"`) is configured by default: Claude consults Opus +at decision points (requires the Anthropic API). It's a no-op if you run a main model that outranks +Opus (e.g. `--model fable`, where an Opus advisor is rejected). > ⚠️ **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 diff --git a/SECURITY.md b/SECURITY.md index 6414ae0..fd58e36 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -54,9 +54,19 @@ may be root. Consequently: ## Known limitations (by design) -- **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. +- **The image ENTRYPOINT runs in auto mode by default, not full bypass.** `permissions.defaultMode` + is `"auto"` (`~/.claude/settings.json`): the agent runs autonomously, but a background classifier + vets actions and blocks dangerous ones. Full bypass (`--dangerously-skip-permissions`) is now + **opt-in** via `CLAUDE_BYPASS_PERMISSIONS=1`, for isolated/throwaway containers that accept no + in-app checks. The OS-level container boundary is still the perimeter; a bare `docker run` without + a wrapper has no host-level hardening — always use the wrapper scripts. Two by-design auto-mode + behaviors: (a) on entering auto, blanket `Bash(*)` / `Agent` allow rules are dropped (the + classifier takes over; narrow rules carry over); (b) in non-interactive `-p` runs, repeated + classifier blocks abort the session (no user to prompt). +- **Auto mode can silently fall back to `default`.** If the account/model doesn't support auto + (e.g. Team/Enterprise without admin-enabled auto, or a non-API provider), Claude Code starts in + `default` mode with no error — meaning it prompts on most actions. Verify the status bar shows + `auto` on first run; set `CLAUDE_BYPASS_PERMISSIONS=1` if you need unattended operation regardless. - **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` @@ -67,8 +77,11 @@ may be root. Consequently: On a root host this is an exfiltration channel under prompt injection — restrict egress at the Docker-network/daemon level (cap-drop=ALL prevents in-container iptables) or remove those MCP servers from `mcp-servers.json`. -- **Prompt injection (the main runtime risk).** With `--dangerously-skip-permissions`, any file in - the project is executable instructions for the autonomous agent. The project is mounted +- **Prompt injection (the main runtime risk).** Any file in the project is potential instructions for + the autonomous agent. In the default **auto mode** the classifier blocks the worst injected actions + (exfiltration to external endpoints, `curl | bash`, force-push, prod deploys) — but it is a research + preview, not a guarantee, and boundaries you state in chat can be lost to context compaction. With + `CLAUDE_BYPASS_PERMISSIONS=1` there are **no** in-app checks at all. The project is mounted **read-write**, so an injected agent can modify project files, push (if a deploy key is set), and exfiltrate code via egress. Residual risk is **Medium** with a scoped deploy key (below). **Use on trusted projects only** and review what the agent commits. @@ -100,9 +113,9 @@ Two additional entrypoints exist for IDE use; both change the posture relative t - **ACP adapter (`run_acp.sh` → `claude-agent-acp`).** The editor (e.g. Zed) launches the container over stdio and drives it via the Agent Client Protocol. Crucially, this path does **not** pass `--dangerously-skip-permissions`; tool calls (edits, shell commands) are sent back to the editor as - **permission requests a human approves**. So the ACP path is *less* permissive than the skip-all - agent entrypoint — a human gates actions, which is a genuine deviation from "the container boundary - is the only perimeter." It keeps the same hardening (`cap-drop=ALL`, `no-new-privileges`, bridge + **permission requests a human approves**. So the ACP path is *less* permissive than the autonomous + agent entrypoint — a human gates each action, versus auto mode's background classifier (or full + bypass under `CLAUDE_BYPASS_PERMISSIONS=1`). It keeps the same hardening (`cap-drop=ALL`, `no-new-privileges`, bridge network, non-root `--user`, tmpfs HOME) with a raised `--pids-limit` (interactive tooling forks more). The project is bind-mounted **read-write at its host-absolute path** (path coherence for the editor); the same `DEPLOY_KEY` model gates `git push`. Outbound network and prompt-injection risks diff --git a/claude-config.json b/claude-config.json index c856a33..fc6b39b 100644 --- a/claude-config.json +++ b/claude-config.json @@ -6,12 +6,8 @@ "autoConnectIde": false, "claudeInChromeDefaultEnabled": false, "showExpandedTodos": true, - "dangerouslySkipPermissions": true, - "autoAcceptPermissions": true, "autoTrustNewProjects": true, "suppressTrustPrompts": true, - "suppressPermissionPrompts": true, - "defaultPermissionMode": "accept", "customApiKeyResponses": { "approved": [], "rejected": [] @@ -29,7 +25,7 @@ "allow": [ "Bash(*)", "Edit(*)", - "Read(*)", + "Read(*)", "Write(*)", "List(*)", "Glob(*)", @@ -41,14 +37,11 @@ "NotebookWrite(*)", "mcp__*" ], - "defaultMode": "acceptEdits", "additionalDirectories": ["/workspace/input", "/workspace/output", "/workspace/data", "/workspace/temp"] }, "hasCompletedProjectOnboarding": true, "hasClaudeMdExternalIncludesApproved": false, "hasClaudeMdExternalIncludesWarningShown": false, - "autoAcceptAllTools": true, - "autoAcceptMcpTools": true, "history": [], "mcpContextUris": [], "mcpServers": { @@ -56,9 +49,6 @@ "enabledMcpjsonServers": [], "disabledMcpjsonServers": [], "hasTrustDialogAccepted": true, - "hasAllToolsApproved": true, - "autoAcceptEdits": true, - "skipConfirmationDialogs": true, "projectOnboardingSeenCount": 1, "thinkingMigrationComplete": true, "allowWebSearch": true, diff --git a/settings.json b/settings.json new file mode 100644 index 0000000..7191017 --- /dev/null +++ b/settings.json @@ -0,0 +1,6 @@ +{ + "permissions": { + "defaultMode": "auto" + }, + "advisorModel": "opus" +}