From 5974347913bf25af7cda1277862523f3c3483e4f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Fri, 1 May 2026 12:58:54 -0700 Subject: [PATCH] Add per-project sandbox mode and Bedrock service-tier MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sandbox mode: new per-project toggle that turns on Claude Code's bash sandbox inside the container. Adds `bubblewrap` and `socat` to the Dockerfile (the two Linux deps required by the sandbox), and emits a managed `sandbox` block into `~/.claude/settings.json` via the existing CLAUDE_CODE_SETTINGS_JSON entrypoint merge: - `enabled` mirrors the Triple-C toggle and is always emitted, so the entrypoint's recursive jq merge clears any prior on-state from the persisted named volume — Triple-C is authoritative. - `enableWeakerNestedSandbox: true` because we run inside Docker without privileged user namespaces. - `allowUnsandboxedCommands: false` to disable the `dangerouslyDisableSandbox` escape hatch — opting into the sandbox shouldn't come with a runtime bypass. When sandbox is on, a SANDBOX_INSTRUCTIONS section is appended to CLAUDE_INSTRUCTIONS so Claude can guide users through allowing extra paths/domains, excluding `docker *`/`watchman *` from the sandbox, and the rule that `sandbox.enabled` is owned by Triple-C. The Claude-Code settings fingerprint includes sandbox state (only when on, to avoid spuriously flagging existing containers for recreation on upgrade). Bedrock service tier: new optional field on the per-project Bedrock config. When set, exported as ANTHROPIC_BEDROCK_SERVICE_TIER (added in Claude Code 2.1.122) and included in the Bedrock fingerprint. Co-Authored-By: Claude Opus 4.7 (1M context) --- app/src-tauri/src/docker/container.rs | 142 ++++++++++++++++---- app/src-tauri/src/models/project.rs | 7 + app/src/components/projects/ProjectCard.tsx | 48 +++++++ app/src/lib/types.ts | 2 + container/Dockerfile | 2 + 5 files changed, 177 insertions(+), 24 deletions(-) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index c8d29cd..7366109 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -88,6 +88,40 @@ This project uses **Flight Control** (bundled with Triple-C) for structured deve 3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored 4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#; +const SANDBOX_INSTRUCTIONS: &str = r#"## Sandbox Mode + +This container has Claude Code's bash sandbox enabled, managed by Triple-C +(toggle it from the project's "Sandbox mode" switch in the Triple-C UI). +Bash commands run inside `bubblewrap` with filesystem and network isolation +(`enableWeakerNestedSandbox` is on because we are inside Docker). + +### When a command fails because of sandbox restrictions + +Triple-C disables the `dangerouslyDisableSandbox` escape hatch +(`allowUnsandboxedCommands: false`), so failing commands cannot bypass the +sandbox at runtime. To make a blocked command work, edit +`~/.claude/settings.json` and restart Claude Code: + +| Need | Setting | +|---|---| +| Write to a path outside the project (e.g. `~/.kube`) | Add to `sandbox.filesystem.allowWrite` | +| Reach a new domain | Will prompt; or add permanently to `sandbox.allowedDomains` | +| Run a specific tool entirely outside the sandbox | Add a glob (e.g. `"docker *"`) to `sandbox.excludedCommands` | + +### Docker commands + +The `docker` CLI does not work inside the sandbox. If this project has +"Allow container spawning" enabled in Triple-C and you need to run +`docker` commands, add `"docker *"` to `sandbox.excludedCommands` in +`~/.claude/settings.json`. Other tools known to be sandbox-incompatible +include `watchman` — pass `--no-watchman` to `jest`. + +### Disabling sandbox mode + +Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it +on every container start. To turn sandbox off, stop the container in +Triple-C, flip the "Sandbox mode" switch off, then start the container."#; + /// Build the full CLAUDE_INSTRUCTIONS value by merging global + project /// instructions, appending port mapping docs, and appending scheduler docs. /// Used by both create_container() and container_needs_recreation() to ensure @@ -97,6 +131,7 @@ fn build_claude_instructions( project_instructions: Option<&str>, port_mappings: &[PortMapping], mission_control_enabled: bool, + sandbox_enabled: bool, ) -> Option { let mut combined = merge_claude_instructions( global_instructions, @@ -126,6 +161,13 @@ fn build_claude_instructions( None => SCHEDULER_INSTRUCTIONS.to_string(), }); + if sandbox_enabled { + combined = Some(match combined { + Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS), + None => SANDBOX_INSTRUCTIONS.to_string(), + }); + } + combined } @@ -227,6 +269,7 @@ fn compute_bedrock_fingerprint(project: &Project) -> String { bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(), bedrock.model_id.as_deref().unwrap_or("").to_string(), format!("{}", bedrock.disable_prompt_caching), + bedrock.service_tier.as_deref().unwrap_or("").to_string(), ]; sha256_hex(&parts.join("|")) } else { @@ -312,8 +355,16 @@ fn merge_claude_code_settings( } /// Compute a fingerprint for the Claude Code settings so we can detect changes. -fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String { - match settings { +/// The `sandbox_enabled` flag is included so that toggling sandbox mode forces +/// a container recreation (re-injecting the merged settings.json). When +/// sandbox is off the historical fingerprint is preserved unchanged so that +/// upgrading triple-c does not spuriously flag every existing container for +/// recreation. +fn compute_claude_code_settings_fingerprint( + settings: Option<&ClaudeCodeSettings>, + sandbox_enabled: bool, +) -> String { + let base_fp = match settings { None => String::new(), Some(s) => { let parts = vec![ @@ -328,30 +379,59 @@ fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings ]; sha256_hex(&parts.join("|")) } + }; + if sandbox_enabled { + sha256_hex(&format!("{}|sandbox=true", base_fp)) + } else { + base_fp } } -/// Build the settings.json content for Claude Code from ClaudeCodeSettings. +/// Build the settings.json content for Claude Code. /// Returns a JSON string of the settings to be written to ~/.claude/settings.json. -fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option { +/// Always emits a `sandbox.enabled` key reflecting the current per-project +/// toggle so that flipping it off in triple-c overrides any prior on-state +/// stored in the persisted settings.json (which lives in a named volume). +fn build_claude_code_settings_json( + settings: Option<&ClaudeCodeSettings>, + sandbox_enabled: bool, +) -> Option { let mut map = serde_json::Map::new(); - if let Some(ref tui) = settings.tui_mode { - map.insert("tui".to_string(), serde_json::json!(tui)); - } - if let Some(ref effort) = settings.effort { - map.insert("effort".to_string(), serde_json::json!(effort)); - } - if settings.auto_scroll_disabled { - map.insert("autoScrollEnabled".to_string(), serde_json::json!(false)); - } - if settings.focus_mode { - map.insert("focusMode".to_string(), serde_json::json!(true)); - } - if settings.show_thinking_summaries { - map.insert("showThinkingSummaries".to_string(), serde_json::json!(true)); + if let Some(s) = settings { + if let Some(ref tui) = s.tui_mode { + map.insert("tui".to_string(), serde_json::json!(tui)); + } + if let Some(ref effort) = s.effort { + map.insert("effort".to_string(), serde_json::json!(effort)); + } + if s.auto_scroll_disabled { + map.insert("autoScrollEnabled".to_string(), serde_json::json!(false)); + } + if s.focus_mode { + map.insert("focusMode".to_string(), serde_json::json!(true)); + } + if s.show_thinking_summaries { + map.insert("showThinkingSummaries".to_string(), serde_json::json!(true)); + } } + // Always emit `sandbox.enabled` so that toggling the per-project sandbox + // off in triple-c clears any prior on-state in the persisted + // settings.json (which lives in a named volume that survives recreation). + // Inside a Docker container we can't rely on privileged user namespaces, + // so `enableWeakerNestedSandbox` is required when sandbox is on. + let sandbox_obj = if sandbox_enabled { + serde_json::json!({ + "enabled": true, + "enableWeakerNestedSandbox": true, + "allowUnsandboxedCommands": false, + }) + } else { + serde_json::json!({ "enabled": false }) + }; + map.insert("sandbox".to_string(), sandbox_obj); + if map.is_empty() { None } else { @@ -586,6 +666,13 @@ pub async fn create_container( if bedrock.disable_prompt_caching { env_vars.push("DISABLE_PROMPT_CACHING=1".to_string()); } + + if let Some(ref tier) = bedrock.service_tier { + let trimmed = tier.trim(); + if !trimmed.is_empty() { + env_vars.push(format!("ANTHROPIC_BEDROCK_SERVICE_TIER={}", trimmed)); + } + } } } @@ -652,6 +739,7 @@ pub async fn create_container( project.claude_instructions.as_deref(), &project.port_mappings, project.mission_control_enabled, + project.sandbox_mode_enabled, ); if let Some(ref instructions) = combined_instructions { @@ -683,11 +771,16 @@ pub async fn create_container( if cc.prompt_caching_1h { env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string()); } + } - // settings.json-based settings (written by the entrypoint) - if let Some(settings_json) = build_claude_code_settings_json(cc) { - env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json)); - } + // settings.json-based settings (written by the entrypoint). + // Always invoked so per-project sandbox state is injected even when no + // ClaudeCodeSettings struct is present. + if let Some(settings_json) = build_claude_code_settings_json( + merged_cc_settings.as_ref(), + project.sandbox_mode_enabled, + ) { + env_vars.push(format!("CLAUDE_CODE_SETTINGS_JSON={}", settings_json)); } let mut mounts: Vec = Vec::new(); @@ -821,7 +914,7 @@ pub async fn create_container( labels.insert("triple-c.mission-control".to_string(), project.mission_control_enabled.to_string()); labels.insert("triple-c.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone()); labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), - compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref())); + compute_claude_code_settings_fingerprint(merged_cc_settings.as_ref(), project.sandbox_mode_enabled)); labels.insert("triple-c.instructions-fingerprint".to_string(), combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); labels.insert("triple-c.git-user-name".to_string(), effective_git_name.unwrap_or_default().to_string()); @@ -1179,6 +1272,7 @@ pub async fn container_needs_recreation( project.claude_instructions.as_deref(), &project.port_mappings, project.mission_control_enabled, + project.sandbox_mode_enabled, ); let expected_instructions_fp = expected_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default(); let container_instructions_fp = get_label("triple-c.instructions-fingerprint").unwrap_or_default(); @@ -1192,7 +1286,7 @@ pub async fn container_needs_recreation( global_claude_code_settings, project.claude_code_settings.as_ref(), ); - let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref()); + let expected_cc_fp = compute_claude_code_settings_fingerprint(merged_cc.as_ref(), project.sandbox_mode_enabled); let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default(); if container_cc_fp != expected_cc_fp { log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp); diff --git a/app/src-tauri/src/models/project.rs b/app/src-tauri/src/models/project.rs index dc2c339..ab7c57f 100644 --- a/app/src-tauri/src/models/project.rs +++ b/app/src-tauri/src/models/project.rs @@ -73,6 +73,8 @@ pub struct Project { pub openai_compatible_config: Option, pub allow_docker_access: bool, #[serde(default)] + pub sandbox_mode_enabled: bool, + #[serde(default)] pub mission_control_enabled: bool, #[serde(default = "default_full_permissions")] pub full_permissions: bool, @@ -159,6 +161,10 @@ pub struct BedrockConfig { pub aws_bearer_token: Option, pub model_id: Option, pub disable_prompt_caching: bool, + /// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var + /// (e.g. "priority"). Empty/None means leave unset. + #[serde(default)] + pub service_tier: Option, } /// Ollama configuration for a project. @@ -199,6 +205,7 @@ impl Project { ollama_config: None, openai_compatible_config: None, allow_docker_access: false, + sandbox_mode_enabled: false, mission_control_enabled: false, full_permissions: false, ssh_key_path: None, diff --git a/app/src/components/projects/ProjectCard.tsx b/app/src/components/projects/ProjectCard.tsx index 5cca104..a3e575d 100644 --- a/app/src/components/projects/ProjectCard.tsx +++ b/app/src/components/projects/ProjectCard.tsx @@ -60,6 +60,7 @@ export default function ProjectCard({ project }: Props) { const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? ""); const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? ""); const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? ""); + const [bedrockServiceTier, setBedrockServiceTier] = useState(project.bedrock_config?.service_tier ?? ""); // Ollama local state const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); @@ -88,6 +89,7 @@ export default function ProjectCard({ project }: Props) { setBedrockProfile(project.bedrock_config?.aws_profile ?? ""); setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? ""); + setBedrockServiceTier(project.bedrock_config?.service_tier ?? ""); setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); setOllamaModelId(project.ollama_config?.model_id ?? ""); setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000"); @@ -192,6 +194,7 @@ export default function ProjectCard({ project }: Props) { aws_bearer_token: null, model_id: null, disable_prompt_caching: false, + service_tier: null, }; const defaultOllamaConfig: OllamaConfig = { @@ -339,6 +342,16 @@ export default function ProjectCard({ project }: Props) { } }; + const handleBedrockServiceTierBlur = async () => { + try { + const current = project.bedrock_config ?? defaultBedrockConfig; + const trimmed = bedrockServiceTier.trim(); + await update({ ...project, bedrock_config: { ...current, service_tier: trimmed || null } }); + } catch (err) { + console.error("Failed to update Bedrock service tier:", err); + } + }; + const handleOllamaBaseUrlBlur = async () => { try { const current = project.ollama_config ?? defaultOllamaConfig; @@ -692,6 +705,28 @@ export default function ProjectCard({ project }: Props) { + {/* Sandbox mode toggle */} +
+ + +
+ {/* Mission Control toggle */}
@@ -953,6 +988,19 @@ export default function ProjectCard({ project }: Props) { className={inputCls} />
+ + {/* Service tier */} +
+ + setBedrockServiceTier(e.target.value)} + onBlur={handleBedrockServiceTierBlur} + placeholder="(default)" + disabled={!isStopped} + className={inputCls} + /> +
); })()} diff --git a/app/src/lib/types.ts b/app/src/lib/types.ts index 4baabb6..7fe1f39 100644 --- a/app/src/lib/types.ts +++ b/app/src/lib/types.ts @@ -25,6 +25,7 @@ export interface Project { ollama_config: OllamaConfig | null; openai_compatible_config: OpenAiCompatibleConfig | null; allow_docker_access: boolean; + sandbox_mode_enabled: boolean; mission_control_enabled: boolean; full_permissions: boolean; ssh_key_path: string | null; @@ -61,6 +62,7 @@ export interface BedrockConfig { aws_bearer_token: string | null; model_id: string | null; disable_prompt_caching: boolean; + service_tier: string | null; } export interface OllamaConfig { diff --git a/container/Dockerfile b/container/Dockerfile index b867832..1a7e706 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -31,6 +31,8 @@ RUN for i in 1 2 3 4 5; do \ pkg-config \ libssl-dev \ cron \ + bubblewrap \ + socat \ && rm -rf /var/lib/apt/lists/* # Remove default ubuntu user to free UID 1000 for host-user remapping