cc-sandboxer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,682 @@
1
+ #!/usr/bin/env bash
2
+ # ╔══════════════════════════════════════════════════════════════════╗
3
+ # ║ 🧠 Claude Code Sandbox ║
4
+ # ║ Run claude --dangerously-skip-permissions safely in Docker ║
5
+ # ║ Works with Docker Desktop, OrbStack, Colima & VS Code ║
6
+ # ╚══════════════════════════════════════════════════════════════════╝
7
+
8
+ set -euo pipefail
9
+
10
+ # ── Version ──────────────────────────────────────────────────
11
+ SCRIPT_VERSION="1.0.0"
12
+
13
+ # ── Config ───────────────────────────────────────────────────
14
+ IMAGE_NAME="cc-sandboxer"
15
+ IMAGE_TAG="latest"
16
+ CONTAINER_NAME="cc-sandboxer"
17
+ TZ="${TZ:-Asia/Ho_Chi_Minh}"
18
+ SOURCE="${BASH_SOURCE[0]}"
19
+ while [[ -L "$SOURCE" ]]; do
20
+ DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
21
+ SOURCE="$(readlink "$SOURCE")"
22
+ [[ "$SOURCE" != /* ]] && SOURCE="$DIR/$SOURCE"
23
+ done
24
+ SCRIPT_DIR="$(cd "$(dirname "$SOURCE")" && pwd)"
25
+
26
+ # ── Colors & Styles ──────────────────────────────────────────
27
+ RED='\033[0;31m'
28
+ GREEN='\033[0;32m'
29
+ YELLOW='\033[1;33m'
30
+ MAGENTA='\033[0;35m'
31
+ CYAN='\033[0;36m'
32
+ WHITE='\033[1;37m'
33
+ GRAY='\033[0;90m'
34
+ BOLD='\033[1m'
35
+ DIM='\033[2m'
36
+ UNDERLINE='\033[4m'
37
+ NC='\033[0m'
38
+
39
+ # ── Emoji icons ──────────────────────────────────────────────
40
+ I_ROCKET="🚀"
41
+ I_BRAIN="🧠"
42
+ I_SHIELD="🛡️"
43
+ I_LOCK="🔒"
44
+ I_CHECK="✅"
45
+ I_CROSS="❌"
46
+ I_WARN="⚠️"
47
+ I_INFO="💡"
48
+ I_FOLDER="📁"
49
+ I_DOCKER="🐳"
50
+ I_GEAR="⚙️"
51
+ I_KEY="🔑"
52
+ I_GLOBE="🌐"
53
+ I_CLOCK="⏱️"
54
+ I_PACKAGE="📦"
55
+ I_SHELL="🐚"
56
+ I_SPARKLE="✨"
57
+ I_LINK="🔗"
58
+ I_ZAP="⚡"
59
+ I_PLUG="🔌"
60
+ I_VSCODE="💻"
61
+
62
+ # ── Logging ──────────────────────────────────────────────────
63
+ log() { echo -e " ${GREEN}${I_CHECK}${NC} $*"; }
64
+ err() { echo -e " ${RED}${I_CROSS}${NC} ${RED}$*${NC}" >&2; }
65
+ step() { echo -e " ${MAGENTA}${I_GEAR}${NC} ${BOLD}$*${NC}"; }
66
+ success() { echo -e " ${GREEN}${I_SPARKLE}${NC} ${GREEN}${BOLD}$*${NC}"; }
67
+
68
+ divider() {
69
+ echo -e " ${DIM}─────────────────────────────────────────────────────────${NC}"
70
+ }
71
+
72
+ # ── Banner ───────────────────────────────────────────────────
73
+ show_banner() {
74
+ echo ""
75
+ echo -e "${BOLD}${CYAN}"
76
+ cat << 'BANNER'
77
+ ██████╗██╗ █████╗ ██╗ ██╗██████╗ ███████╗
78
+ ██╔════╝██║ ██╔══██╗██║ ██║██╔══██╗██╔════╝
79
+ ██║ ██║ ███████║██║ ██║██║ ██║█████╗
80
+ ██║ ██║ ██╔══██║██║ ██║██║ ██║██╔══╝
81
+ ╚██████╗███████╗██║ ██║╚██████╔╝██████╔╝███████╗
82
+ ╚═════╝╚══════╝╚═╝ ╚═╝ ╚═════╝ ╚═════╝ ╚══════╝
83
+ BANNER
84
+ echo -e "${NC}"
85
+ echo -e " ${BOLD}${WHITE}C O D E S A N D B O X${NC} ${DIM}v${SCRIPT_VERSION}${NC}"
86
+ echo -e " ${DIM}Run skip-permissions safely in a sandbox${NC}"
87
+ echo ""
88
+ divider
89
+ echo ""
90
+ }
91
+
92
+ # ── Help ─────────────────────────────────────────────────────
93
+ show_help() {
94
+ show_banner
95
+ echo -e " ${BOLD}${WHITE}USAGE${NC}"
96
+ echo ""
97
+ echo -e " ${GREEN}cc-sandboxer${NC} ${DIM}[project_path]${NC} ${DIM}[options]${NC}"
98
+ echo ""
99
+ echo -e " ${BOLD}${WHITE}MODES${NC}"
100
+ echo ""
101
+ echo -e " ${I_ROCKET} ${BOLD}CLI Mode${NC} ${DIM}(default)${NC} — Run directly from terminal"
102
+ echo -e " ${I_VSCODE} ${BOLD}VS Code Mode${NC} — Use ${GREEN}--init${NC} to setup DevContainer"
103
+ echo ""
104
+ echo -e " ${BOLD}${WHITE}ARGUMENTS${NC}"
105
+ echo ""
106
+ echo -e " ${WHITE}project_path${NC} Path to project ${DIM}(default: current directory)${NC}"
107
+ echo ""
108
+ echo -e " ${BOLD}${WHITE}OPTIONS — Setup${NC}"
109
+ echo ""
110
+ echo -e " ${GREEN}--init${NC} ${I_VSCODE} Setup devcontainer + VS Code tasks in project"
111
+ echo -e " ${GREEN}--rebuild${NC} ${I_PACKAGE} Force rebuild Docker image"
112
+ echo -e " ${GREEN}--version${NC}, ${GREEN}-v${NC} ${I_INFO} Show version"
113
+ echo -e " ${GREEN}--help${NC}, ${GREEN}-h${NC} ${I_INFO} Show this help"
114
+ echo ""
115
+ echo -e " ${BOLD}${WHITE}OPTIONS — Runtime${NC}"
116
+ echo ""
117
+ echo -e " ${GREEN}--shell${NC} ${I_SHELL} Open shell only (don't start Claude)"
118
+ echo -e " ${GREEN}--no-firewall${NC} ${I_GLOBE} Skip firewall setup"
119
+ echo -e " ${GREEN}--allow-domain${NC} ${DIM}NAME${NC} ${I_PLUG} Whitelist extra domain ${DIM}(repeatable)${NC}"
120
+ echo -e " ${GREEN}--continue${NC}, ${GREEN}-c${NC} ${I_LINK} Resume previous conversation"
121
+ echo -e " ${GREEN}-p${NC} ${DIM}\"prompt\"${NC} ${I_ZAP} One-shot task mode"
122
+ echo -e " ${GREEN}--disallowedTools${NC} ${DIM}T${NC} ${I_SHIELD} Block specific tools"
123
+ echo ""
124
+ echo -e " ${BOLD}${WHITE}EXAMPLES${NC}"
125
+ echo ""
126
+ echo -e " ${DIM}# Quick start — interactive mode${NC}"
127
+ echo -e " ${GREEN}\$${NC} cc-sandboxer"
128
+ echo ""
129
+ echo -e " ${DIM}# Setup VS Code DevContainer in your project${NC}"
130
+ echo -e " ${GREEN}\$${NC} cc-sandboxer --init ~/projects/my-app"
131
+ echo ""
132
+ echo -e " ${DIM}# One-shot task${NC}"
133
+ echo -e " ${GREEN}\$${NC} cc-sandboxer . -p \"Refactor auth and write tests\""
134
+ echo ""
135
+ echo -e " ${DIM}# Resume last conversation${NC}"
136
+ echo -e " ${GREEN}\$${NC} cc-sandboxer . --continue"
137
+ echo ""
138
+ echo -e " ${DIM}# Safe mode — block rm commands${NC}"
139
+ echo -e " ${GREEN}\$${NC} cc-sandboxer . --disallowedTools \"Bash(rm:*)\""
140
+ echo ""
141
+ echo -e " ${BOLD}${WHITE}ENVIRONMENT${NC}"
142
+ echo ""
143
+ echo -e " ${WHITE}TZ${NC} Timezone ${DIM}(default: Asia/Ho_Chi_Minh)${NC}"
144
+ echo ""
145
+ divider
146
+ echo ""
147
+ }
148
+
149
+ # ══════════════════════════════════════════════════════════════
150
+ # 💻 VS Code Integration — --init command
151
+ # ══════════════════════════════════════════════════════════════
152
+
153
+ init_vscode_project() {
154
+ local target_path="$1"
155
+
156
+ show_banner
157
+ step "Setting up VS Code DevContainer ${I_VSCODE}"
158
+ echo ""
159
+ echo -e " ${DIM}Target:${NC} ${WHITE}${target_path}${NC}"
160
+ echo ""
161
+
162
+ # ── Create devcontainer/ ────────────────────────────────
163
+ local dc_dir="${target_path}/devcontainer"
164
+ mkdir -p "$dc_dir"
165
+
166
+ # Dockerfile — copy from the project's single source of truth
167
+ cp "${SCRIPT_DIR}/docker/Dockerfile" "${dc_dir}/Dockerfile"
168
+ show_progress_line "Created devcontainer/Dockerfile" "done"
169
+
170
+ # Firewall script — copy from the project's init-firewall.sh
171
+ cp "${SCRIPT_DIR}/docker/init-firewall.sh" "${dc_dir}/init-firewall.sh"
172
+ chmod +x "${dc_dir}/init-firewall.sh"
173
+ show_progress_line "Created devcontainer/init-firewall.sh" "done"
174
+
175
+ # devcontainer.json — copy from the project's single source of truth
176
+ cp "${SCRIPT_DIR}/devcontainer/devcontainer.json" "${dc_dir}/devcontainer.json"
177
+ # Fix Dockerfile path: in target project, Dockerfile is local to devcontainer/
178
+ sed -i.bak 's|"dockerfile": "\.\./docker/Dockerfile"|"dockerfile": "Dockerfile"|' "${dc_dir}/devcontainer.json"
179
+ sed -i.bak '/"context": "\.\.",/d' "${dc_dir}/devcontainer.json"
180
+ rm -f "${dc_dir}/devcontainer.json.bak"
181
+ show_progress_line "Created devcontainer/devcontainer.json" "done"
182
+
183
+ # ── Create .vscode/tasks.json ────────────────────────────
184
+ local vscode_dir="${target_path}/.vscode"
185
+ mkdir -p "$vscode_dir"
186
+
187
+ # Only write tasks.json if it doesn't exist (don't clobber user's tasks)
188
+ if [[ ! -f "${vscode_dir}/tasks.json" ]]; then
189
+ cp "${SCRIPT_DIR}/vscode/tasks.json" "${vscode_dir}/tasks.json"
190
+ show_progress_line "Created .vscode/tasks.json" "done"
191
+ else
192
+ show_progress_line "Skipped .vscode/tasks.json (already exists)" "skip"
193
+ fi
194
+
195
+
196
+ # ── Summary ──────────────────────────────────────────────
197
+ echo ""
198
+ divider
199
+ echo ""
200
+ success "VS Code DevContainer setup complete! ${I_SPARKLE}"
201
+ echo ""
202
+ echo -e " ${BOLD}${WHITE}Files created:${NC}"
203
+ echo ""
204
+ echo -e " ${DIM}${target_path}/${NC}"
205
+ echo -e " ${DIM}├── ${NC}${WHITE}devcontainer/${NC}"
206
+ echo -e " ${DIM}│ ├── ${NC}${CYAN}Dockerfile${NC}"
207
+ echo -e " ${DIM}│ ├── ${NC}${CYAN}devcontainer.json${NC}"
208
+ echo -e " ${DIM}│ └── ${NC}${CYAN}init-firewall.sh${NC}"
209
+ echo -e " ${DIM}└── ${NC}${WHITE}.vscode/${NC}"
210
+ echo -e " ${DIM} └── ${NC}${CYAN}tasks.json${NC}"
211
+ echo ""
212
+ divider
213
+ echo ""
214
+ echo -e " ${BOLD}${WHITE}Next steps:${NC}"
215
+ echo ""
216
+ echo -e " ${YELLOW}1.${NC} Open the project in VS Code"
217
+ echo -e " ${GREEN}\$${NC} code ${target_path}"
218
+ echo ""
219
+ echo -e " ${YELLOW}2.${NC} Reopen in Container"
220
+ echo -e " ${DIM}Cmd+Shift+P → \"Dev Containers: Reopen in Container\"${NC}"
221
+ echo ""
222
+ echo -e " ${YELLOW}3.${NC} Login (first time only)"
223
+ echo -e " ${DIM}Cmd+Shift+P → \"Tasks: Run Task\" → \"${I_KEY} Claude: Login\"${NC}"
224
+ echo ""
225
+ echo -e " ${YELLOW}4.${NC} Start Claude!"
226
+ echo -e " ${DIM}Cmd+Shift+P → \"Tasks: Run Task\" → \"${I_BRAIN} Claude: Skip Permissions\"${NC}"
227
+ echo ""
228
+ echo -e " ${BOLD}${WHITE}Available VS Code Tasks:${NC}"
229
+ echo ""
230
+ echo -e " ${I_BRAIN} ${WHITE}Claude: Skip Permissions${NC} ${DIM}— Interactive mode${NC}"
231
+ echo -e " ${I_LINK} ${WHITE}Claude: Resume Last Chat${NC} ${DIM}— Continue previous chat${NC}"
232
+ echo -e " ${I_ZAP} ${WHITE}Claude: One-Shot Task${NC} ${DIM}— Custom prompt input${NC}"
233
+ echo -e " ${I_SHIELD} ${WHITE}Claude: Safe Mode (no rm)${NC} ${DIM}— Block file deletion${NC}"
234
+ echo -e " ${I_LOCK} ${WHITE}Firewall: Re-initialize${NC} ${DIM}— Re-apply firewall${NC}"
235
+ echo -e " ${I_KEY} ${WHITE}Claude: Login${NC} ${DIM}— First-time auth${NC}"
236
+ echo ""
237
+ divider
238
+ echo ""
239
+ echo -e " ${YELLOW}${I_WARN} ${BOLD}Claude Code Extension:${NC}"
240
+ echo -e " ${DIM}If the extension is disabled inside the container, install it manually:${NC}"
241
+ echo -e " ${WHITE}Cmd+Shift+X${NC} ${DIM}→${NC} ${WHITE}Search \"Claude Code\"${NC} ${DIM}→${NC} ${WHITE}Install in Dev Container${NC}"
242
+ echo -e " ${DIM}This only needs to be done once — it persists across rebuilds.${NC}"
243
+ echo ""
244
+ divider
245
+ echo ""
246
+ }
247
+
248
+ # ── Progress line helper ─────────────────────────────────────
249
+ show_progress_line() {
250
+ local label="$1"
251
+ local status="$2"
252
+
253
+ case "$status" in
254
+ running) echo -e " ${CYAN}▸${NC} ${label} ${DIM}...${NC}" ;;
255
+ done) echo -e " ${GREEN}●${NC} ${label}" ;;
256
+ skip) echo -e " ${YELLOW}○${NC} ${label}" ;;
257
+ fail) echo -e " ${RED}✖${NC} ${label}" ;;
258
+ esac
259
+ }
260
+
261
+ # ══════════════════════════════════════════════════════════════
262
+ # 🐳 CLI Mode — Docker direct
263
+ # ══════════════════════════════════════════════════════════════
264
+
265
+ # ── Parse args ───────────────────────────────────────────────
266
+ PROJECT_PATH=""
267
+ CLAUDE_ARGS=()
268
+ FORCE_REBUILD=false
269
+ SHELL_ONLY=false
270
+ NO_FIREWALL=false
271
+ INIT_MODE=false
272
+ EXTRA_DOMAINS=()
273
+
274
+ while [[ $# -gt 0 ]]; do
275
+ case "$1" in
276
+ --init)
277
+ INIT_MODE=true; shift ;;
278
+ --rebuild)
279
+ FORCE_REBUILD=true; shift ;;
280
+ --shell)
281
+ SHELL_ONLY=true; shift ;;
282
+ --no-firewall)
283
+ NO_FIREWALL=true; shift ;;
284
+ --allow-domain)
285
+ if [[ -z "${2:-}" || "$2" == --* ]]; then
286
+ err "--allow-domain requires a domain argument"
287
+ exit 1
288
+ fi
289
+ # Validate domain format (alphanumeric, dots, hyphens, wildcards)
290
+ if [[ ! "$2" =~ ^[a-zA-Z0-9.*-]+$ ]]; then
291
+ err "Invalid domain format: $2"
292
+ exit 1
293
+ fi
294
+ EXTRA_DOMAINS+=("$2"); shift 2 ;;
295
+ --continue|-c)
296
+ CLAUDE_ARGS+=("--continue"); shift ;;
297
+ -p)
298
+ CLAUDE_ARGS+=("-p" "$2"); shift 2 ;;
299
+ --disallowedTools)
300
+ CLAUDE_ARGS+=("--disallowedTools" "$2"); shift 2 ;;
301
+ --version|-v)
302
+ echo "cc-sandboxer v${SCRIPT_VERSION}"; exit 0 ;;
303
+ --help|-h)
304
+ show_help; exit 0 ;;
305
+ -*)
306
+ CLAUDE_ARGS+=("$1"); shift ;;
307
+ *)
308
+ if [[ -z "$PROJECT_PATH" ]]; then
309
+ PROJECT_PATH="$1"
310
+ else
311
+ CLAUDE_ARGS+=("$1")
312
+ fi
313
+ shift ;;
314
+ esac
315
+ done
316
+
317
+ # Default project path
318
+ if [[ -z "$PROJECT_PATH" ]]; then
319
+ PROJECT_PATH="$(pwd)"
320
+ fi
321
+ # Create directory if it doesn't exist (useful for --init with new projects)
322
+ if [[ ! -d "$PROJECT_PATH" ]]; then
323
+ if [[ "$INIT_MODE" == "true" ]]; then
324
+ mkdir -p "$PROJECT_PATH"
325
+ else
326
+ err "Project path not found: ${PROJECT_PATH:-<empty>}"
327
+ echo ""
328
+ echo -e " ${BOLD}${WHITE}Usage:${NC}"
329
+ echo -e " ${GREEN}cc-sandboxer${NC} ${DIM}[project_path]${NC}"
330
+ echo -e " ${GREEN}cc-sandboxer --init${NC} ${DIM}~/projects/my-app${NC}"
331
+ echo ""
332
+ exit 1
333
+ fi
334
+ fi
335
+ PROJECT_PATH="$(cd "$PROJECT_PATH" && pwd)"
336
+ PROJECT_NAME="$(basename "$PROJECT_PATH")"
337
+
338
+ # ── Handle --init mode ──────────────────────────────────────
339
+ if [[ "$INIT_MODE" == "true" ]]; then
340
+ init_vscode_project "$PROJECT_PATH"
341
+ exit 0
342
+ fi
343
+
344
+ # ── Detect container runtime ─────────────────────────────────
345
+ detect_runtime() {
346
+ step "Detecting container runtime..."
347
+
348
+ if ! command -v docker &>/dev/null; then
349
+ err "Docker not found!"
350
+ echo ""
351
+ echo -e " ${BOLD}Install one of these:${NC}"
352
+ echo -e " ${I_DOCKER} Docker Desktop ${DIM}→${NC} ${UNDERLINE}https://docker.com/products/docker-desktop${NC}"
353
+ echo -e " ${I_ROCKET} OrbStack (Mac) ${DIM}→${NC} ${UNDERLINE}https://orbstack.dev${NC}"
354
+ echo ""
355
+ exit 1
356
+ fi
357
+
358
+ # Check if Docker daemon is actually running
359
+ if ! docker info &>/dev/null; then
360
+ err "Docker daemon is not running!"
361
+ echo ""
362
+ echo -e " ${BOLD}Start your container runtime:${NC}"
363
+ echo -e " ${I_DOCKER} Docker Desktop ${DIM}→${NC} Open the Docker Desktop app"
364
+ echo -e " ${I_ROCKET} OrbStack ${DIM}→${NC} Open the OrbStack app"
365
+ echo -e " ${I_DOCKER} Colima ${DIM}→${NC} Run ${GREEN}colima start${NC}"
366
+ echo ""
367
+ exit 1
368
+ fi
369
+
370
+ RUNTIME="docker"
371
+ local docker_info
372
+ docker_info=$(docker info 2>/dev/null || true)
373
+
374
+ # Check context name first (most reliable), then fall back to docker info
375
+ local docker_context
376
+ docker_context=$(docker context show 2>/dev/null || echo "")
377
+
378
+ if [[ "$docker_context" == "orbstack" ]]; then
379
+ RUNTIME_LABEL="OrbStack"
380
+ RUNTIME_ICON="${I_ROCKET}"
381
+ elif [[ "$docker_context" == "colima"* ]]; then
382
+ RUNTIME_LABEL="Colima"
383
+ RUNTIME_ICON="🦙"
384
+ elif [[ "$docker_context" == "desktop-linux" ]] || echo "$docker_info" | grep -qi "docker desktop"; then
385
+ RUNTIME_LABEL="Docker Desktop"
386
+ RUNTIME_ICON="${I_DOCKER}"
387
+ else
388
+ RUNTIME_LABEL="Docker Engine"
389
+ RUNTIME_ICON="${I_DOCKER}"
390
+ fi
391
+
392
+ log "Found ${BOLD}${RUNTIME_LABEL}${NC} ${RUNTIME_ICON}"
393
+ }
394
+
395
+ # ── Build image ──────────────────────────────────────────────
396
+ build_image() {
397
+ if [[ "$FORCE_REBUILD" == "true" ]] || ! docker image inspect "$IMAGE_NAME:$IMAGE_TAG" &>/dev/null; then
398
+ echo ""
399
+ step "Building sandbox image ${I_PACKAGE}"
400
+ echo ""
401
+ echo -e " ${DIM}Image:${NC} ${WHITE}${IMAGE_NAME}:${IMAGE_TAG}${NC}"
402
+ echo -e " ${DIM}Timezone:${NC} ${WHITE}${TZ}${NC}"
403
+ echo ""
404
+
405
+ echo -e " ${DIM}${I_CLOCK} Building... (first run takes 2-3 min)${NC}"
406
+ echo ""
407
+
408
+ local build_log
409
+ build_log=$(mktemp /tmp/cc-sandboxer-build-XXXXXXXX)
410
+
411
+ # Count total steps from Dockerfile (each instruction = 1 step)
412
+ local total_steps
413
+ total_steps=$(grep -cE '^\s*(FROM|RUN|COPY|ADD|ENV|ARG|WORKDIR|USER|ENTRYPOINT|CMD|EXPOSE|VOLUME|LABEL|SHELL)\b' "${SCRIPT_DIR}/docker/Dockerfile")
414
+ local current_step=0
415
+ local bar_width=40
416
+
417
+ docker build \
418
+ --build-arg "TZ=$TZ" \
419
+ -t "$IMAGE_NAME:$IMAGE_TAG" \
420
+ "${SCRIPT_DIR}/docker" > "$build_log" 2>&1 &
421
+ local build_pid=$!
422
+
423
+ # Prepare-phase: show gradual progress 0% → 10% while waiting for first step
424
+ local prepare_pct=0
425
+ local prepare_max=70
426
+ local prepare_tick=0
427
+
428
+ # Monitor build progress by watching log for step markers
429
+ while kill -0 "$build_pid" 2>/dev/null; do
430
+ if [[ -f "$build_log" ]]; then
431
+ # Match both legacy "Step N/M" and BuildKit "#N [stage N/M]" formats
432
+ local step_line
433
+ step_line=$(grep -oE '(Step [0-9]+/[0-9]+|#[0-9]+ \[[a-z]+ +[0-9]+/[0-9]+\])' "$build_log" 2>/dev/null | tail -1 || true)
434
+ if [[ -n "$step_line" ]]; then
435
+ # Extract current/total from either format
436
+ local nums
437
+ nums=$(echo "$step_line" | grep -oE '[0-9]+/[0-9]+' | tail -1)
438
+ current_step=${nums%/*}
439
+ total_steps=${nums#*/}
440
+ fi
441
+
442
+ if [[ "$current_step" -eq 0 ]]; then
443
+ # Gradually increase from 0% to prepare_max% during prepare phase
444
+ prepare_tick=$(( prepare_tick + 1 ))
445
+ # Increase every ~3 ticks (0.9s) up to prepare_max
446
+ if [[ $(( prepare_tick % 3 )) -eq 0 && "$prepare_pct" -lt "$prepare_max" ]]; then
447
+ prepare_pct=$(( prepare_pct + 1 ))
448
+ fi
449
+ local filled=$(( prepare_pct * bar_width / 100 ))
450
+ local empty=$(( bar_width - filled ))
451
+ local bar="${CYAN}$(printf '%*s' "$filled" '' | tr ' ' '▓')${GRAY}$(printf '%*s' "$empty" '' | tr ' ' '░')${NC}"
452
+ printf "\r ${bar} ${BOLD}${CYAN}%3d%%${NC} ${DIM}Preparing...${NC} " "$prepare_pct"
453
+ elif [[ "$total_steps" -gt 0 ]]; then
454
+ # Map real progress (step/total) into remaining 10%-100% range
455
+ local real_pct=$(( current_step * 100 / total_steps ))
456
+ local pct=$(( prepare_max + real_pct * (100 - prepare_max) / 100 ))
457
+ local filled=$(( pct * bar_width / 100 ))
458
+ local empty=$(( bar_width - filled ))
459
+ # Gradient color: cyan → green → yellow as progress increases
460
+ local bar_color
461
+ if [[ "$pct" -lt 33 ]]; then
462
+ bar_color="${CYAN}"
463
+ elif [[ "$pct" -lt 66 ]]; then
464
+ bar_color="${GREEN}"
465
+ else
466
+ bar_color="${YELLOW}"
467
+ fi
468
+ local bar="${bar_color}$(printf '%*s' "$filled" '' | tr ' ' '▓')${GRAY}$(printf '%*s' "$empty" '' | tr ' ' '░')${NC}"
469
+ printf "\r ${bar} ${BOLD}${bar_color}%3d%%${NC} ${DIM}(%d/%d)${NC} " "$pct" "$current_step" "$total_steps"
470
+ fi
471
+ fi
472
+ sleep 0.3
473
+ done
474
+
475
+ # Final status
476
+ wait "$build_pid"
477
+ local build_exit=$?
478
+
479
+ if [[ "$build_exit" -eq 0 ]]; then
480
+ printf "\r ${GREEN}$(printf '%*s' "$bar_width" '' | tr ' ' '▓')${NC} ${BOLD}${GREEN}100%%${NC} ${DIM}(%d/%d)${NC} \n" "$total_steps" "$total_steps"
481
+ echo ""
482
+ log "Image built successfully ${I_SPARKLE}"
483
+ rm -f "$build_log"
484
+ else
485
+ echo ""
486
+ err "Image build failed!"
487
+ echo ""
488
+ echo -e " ${BOLD}${WHITE}Last 20 lines of build output:${NC}"
489
+ echo ""
490
+ tail -20 "$build_log" | while IFS= read -r line; do
491
+ echo -e " ${DIM}${line}${NC}"
492
+ done
493
+ echo ""
494
+ echo -e " ${DIM}Full build log:${NC} ${WHITE}${build_log}${NC}"
495
+ echo ""
496
+ echo -e " ${BOLD}${WHITE}Common fixes:${NC}"
497
+ echo -e " ${YELLOW}•${NC} Network issue ${DIM}→${NC} Check internet connection"
498
+ echo -e " ${YELLOW}•${NC} Disk full ${DIM}→${NC} Run ${GREEN}docker system prune${NC}"
499
+ echo -e " ${YELLOW}•${NC} Cached layers ${DIM}→${NC} Run ${GREEN}$0 --rebuild${NC}"
500
+ echo ""
501
+ exit 1
502
+ fi
503
+
504
+ else
505
+ log "Image ${BOLD}${IMAGE_NAME}:${IMAGE_TAG}${NC} ready ${DIM}(use --rebuild to force)${NC}"
506
+ fi
507
+ }
508
+
509
+ # ── Generate firewall script to temp file ─────────────────────
510
+ # Writes firewall script to a temp file to avoid shell injection
511
+ # when embedding in docker run -c commands
512
+ gen_firewall_file() {
513
+ local fw_file="$1"
514
+ # Copy the canonical firewall script and inject extra domains
515
+ cp "${SCRIPT_DIR}/docker/init-firewall.sh" "$fw_file"
516
+
517
+ # Inject extra domains into the ALLOWED_DOMAINS array
518
+ if [[ ${#EXTRA_DOMAINS[@]} -gt 0 ]]; then
519
+ local extra_lines=""
520
+ for d in "${EXTRA_DOMAINS[@]}"; do
521
+ [[ -n "$d" ]] && extra_lines+=" \"$d\"\n"
522
+ done
523
+ if [[ -n "$extra_lines" ]]; then
524
+ # Insert extra domains before the closing paren of ALLOWED_DOMAINS
525
+ local tmp_fw="${fw_file}.tmp"
526
+ awk -v extra="$extra_lines" '/^)$/ && !done { printf "%s", extra; done=1 } { print }' "$fw_file" > "$tmp_fw"
527
+ mv "$tmp_fw" "$fw_file"
528
+ fi
529
+ fi
530
+
531
+ chmod +x "$fw_file"
532
+ }
533
+
534
+ # ── Status box ───────────────────────────────────────────────
535
+ show_launch_box() {
536
+ local mode="$1"
537
+ local box_w=55
538
+
539
+ # Print a row: icon, label, value, value_color
540
+ # Fixed inner width of 55 chars between │ and │
541
+ box_row() {
542
+ local icon="$1" label="$2" value="$3" vcolor="$4"
543
+ local line
544
+ # Build the visible content: " X Label__ Value"
545
+ # icon takes 2 display cols, so we account for that
546
+ line=$(printf " %s %-9s %s" "$icon" "$label" "$value")
547
+ # Calculate visible width (strip ANSI, account for emoji = 2 cols each)
548
+ local visible_len=$(( 2 + 2 + 2 + 9 + 2 + ${#value} )) # emoji=2 display cols
549
+ local pad_len=$(( box_w - visible_len ))
550
+ [[ "$pad_len" -lt 0 ]] && pad_len=0
551
+ printf " ${BOLD}${CYAN}│${NC} %s ${DIM}%-9s${NC} ${vcolor}%s${NC}%*s${BOLD}${CYAN}│${NC}\n" \
552
+ "$icon" "$label" "$value" "$pad_len" ""
553
+ }
554
+
555
+ echo ""
556
+ echo -e " ${BOLD}${CYAN}┌───────────────────────────────────────────────────────┐${NC}"
557
+ echo -e " ${BOLD}${CYAN}│${NC} ${I_BRAIN} ${BOLD}${WHITE}Claude Code Sandbox${NC} ${BOLD}${CYAN}│${NC}"
558
+ echo -e " ${BOLD}${CYAN}├───────────────────────────────────────────────────────┤${NC}"
559
+ echo -e " ${BOLD}${CYAN}│${NC} ${BOLD}${CYAN}│${NC}"
560
+
561
+ box_row "$I_FOLDER" "Project :" "$PROJECT_NAME" "${WHITE}"
562
+ box_row "$RUNTIME_ICON" "Runtime :" "$RUNTIME_LABEL" "${WHITE}"
563
+ box_row "$I_ZAP" "Mode :" "$mode" "${WHITE}"
564
+
565
+ if [[ "$NO_FIREWALL" == "true" ]]; then
566
+ box_row "$I_GLOBE" "Firewall:" "Disabled" "${YELLOW}"
567
+ else
568
+ box_row "$I_LOCK" "Firewall:" "Active" "${GREEN}"
569
+ fi
570
+
571
+ echo -e " ${BOLD}${CYAN}│${NC} ${BOLD}${CYAN}│${NC}"
572
+ echo -e " ${BOLD}${CYAN}└───────────────────────────────────────────────────────┘${NC}"
573
+ echo ""
574
+ }
575
+
576
+ # ── Run container ────────────────────────────────────────────
577
+ run_container() {
578
+ step "Launching container ${I_ROCKET}"
579
+
580
+ # Cleanup existing
581
+ if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
582
+ docker rm -f "$CONTAINER_NAME" &>/dev/null || true
583
+ fi
584
+
585
+ local RUN_ARGS=(
586
+ --rm -it
587
+ --name "$CONTAINER_NAME"
588
+ --cap-add=NET_ADMIN
589
+ -v "$PROJECT_PATH:/workspace:cached"
590
+ -v "${HOME}/.claude:/home/node/.claude:cached"
591
+ -v "claude-npm:/usr/local/share/npm-global"
592
+ -v "claude-history:/commandhistory"
593
+ -e "CLAUDE_CONFIG_DIR=/home/node/.claude"
594
+ -e "TZ=$TZ"
595
+ )
596
+
597
+ # Mount .gitconfig if exists
598
+ if [[ -f "${HOME}/.gitconfig" ]]; then
599
+ RUN_ARGS+=(-v "${HOME}/.gitconfig:/home/node/.gitconfig:ro")
600
+ fi
601
+
602
+ # Generate firewall script as temp file and mount into container
603
+ local FW_SETUP="true"
604
+ if [[ "$NO_FIREWALL" == "false" ]]; then
605
+ local fw_tmp
606
+ # Colima only shares $HOME into its VM — /tmp and $TMPDIR (/var/folders)
607
+ # are not accessible, causing Docker to mount them as empty directories.
608
+ # Place temp file under $HOME to ensure visibility across all runtimes.
609
+ local tmp_base="${HOME}/.cache/cc-sandboxer"
610
+ mkdir -p "$tmp_base"
611
+ fw_tmp=$(mktemp "${tmp_base}/fw-XXXXXXXX")
612
+ gen_firewall_file "$fw_tmp"
613
+ RUN_ARGS+=(-v "$fw_tmp:/opt/init-firewall.sh:ro")
614
+ FW_SETUP="sudo bash /opt/init-firewall.sh"
615
+ fi
616
+
617
+ # Build claude command as an array to avoid injection
618
+ local CLAUDE_CMD="claude --dangerously-skip-permissions"
619
+ if [[ ${#CLAUDE_ARGS[@]} -gt 0 ]]; then
620
+ # Quote each arg individually for safe embedding
621
+ for arg in "${CLAUDE_ARGS[@]}"; do
622
+ CLAUDE_CMD+=" $(printf '%q' "$arg")"
623
+ done
624
+ fi
625
+
626
+ if [[ "$SHELL_ONLY" == "true" ]]; then
627
+ show_launch_box "Shell Only"
628
+
629
+ docker run "${RUN_ARGS[@]}" "$IMAGE_NAME:$IMAGE_TAG" -c "
630
+ ${FW_SETUP} 2>/tmp/fw-err.log || { echo -e '\033[1;33m⚠ Firewall failed to initialize.\033[0m Ensure container has --cap-add=NET_ADMIN.'; cat /tmp/fw-err.log; echo ''; }
631
+ echo ''
632
+ echo 'Shell ready! Start Claude manually:'
633
+ echo ''
634
+ echo ' claude --dangerously-skip-permissions'
635
+ echo ' claude --dangerously-skip-permissions -p \"your task\"'
636
+ echo ' claude --dangerously-skip-permissions --continue'
637
+ echo ''
638
+ exec zsh -l
639
+ "
640
+ else
641
+ show_launch_box "Skip Permissions"
642
+
643
+ echo -e " ${DIM}${I_ZAP} ${CLAUDE_CMD}${NC}"
644
+ echo ""
645
+ divider
646
+ echo ""
647
+
648
+ docker run "${RUN_ARGS[@]}" "$IMAGE_NAME:$IMAGE_TAG" -c "
649
+ ${FW_SETUP} 2>/tmp/fw-err.log || { echo -e '\033[1;33m⚠ Firewall failed to initialize.\033[0m Ensure container has --cap-add=NET_ADMIN.'; cat /tmp/fw-err.log; echo ''; }
650
+ ${CLAUDE_CMD}
651
+ "
652
+ fi
653
+
654
+ # Cleanup temp firewall file
655
+ [[ -f "${fw_tmp:-}" ]] && rm -f "$fw_tmp"
656
+ }
657
+
658
+ # ══════════════════════════════════════════════════════════════
659
+ # 🏁 Main
660
+ # ══════════════════════════════════════════════════════════════
661
+ main() {
662
+ show_banner
663
+ detect_runtime
664
+
665
+ echo ""
666
+ build_image
667
+
668
+ echo ""
669
+ run_container
670
+
671
+ # Post-exit
672
+ echo ""
673
+ divider
674
+ echo ""
675
+ success "Session ended. Your project files are safe ${I_SHIELD}"
676
+ echo ""
677
+ echo -e " ${DIM}Run again :${NC} ${GREEN}cc-sandboxer${NC}"
678
+ echo -e " ${DIM}Resume :${NC} ${GREEN}cc-sandboxer --continue${NC}"
679
+ echo ""
680
+ }
681
+
682
+ main