Add per-project sandbox mode and Bedrock service-tier
Some checks failed
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 8m1s
Build Container / build-container (pull_request) Successful in 8m11s
Build App / build-linux (pull_request) Failing after 1m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-01 12:58:54 -07:00
parent 805f815876
commit 5974347913
5 changed files with 177 additions and 24 deletions

View File

@@ -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 3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)"#; 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 /// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs. /// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure /// Used by both create_container() and container_needs_recreation() to ensure
@@ -97,6 +131,7 @@ fn build_claude_instructions(
project_instructions: Option<&str>, project_instructions: Option<&str>,
port_mappings: &[PortMapping], port_mappings: &[PortMapping],
mission_control_enabled: bool, mission_control_enabled: bool,
sandbox_enabled: bool,
) -> Option<String> { ) -> Option<String> {
let mut combined = merge_claude_instructions( let mut combined = merge_claude_instructions(
global_instructions, global_instructions,
@@ -126,6 +161,13 @@ fn build_claude_instructions(
None => SCHEDULER_INSTRUCTIONS.to_string(), 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 combined
} }
@@ -227,6 +269,7 @@ fn compute_bedrock_fingerprint(project: &Project) -> String {
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(), bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
bedrock.model_id.as_deref().unwrap_or("").to_string(), bedrock.model_id.as_deref().unwrap_or("").to_string(),
format!("{}", bedrock.disable_prompt_caching), format!("{}", bedrock.disable_prompt_caching),
bedrock.service_tier.as_deref().unwrap_or("").to_string(),
]; ];
sha256_hex(&parts.join("|")) sha256_hex(&parts.join("|"))
} else { } else {
@@ -312,8 +355,16 @@ fn merge_claude_code_settings(
} }
/// Compute a fingerprint for the Claude Code settings so we can detect changes. /// Compute a fingerprint for the Claude Code settings so we can detect changes.
fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings>) -> String { /// The `sandbox_enabled` flag is included so that toggling sandbox mode forces
match settings { /// 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(), None => String::new(),
Some(s) => { Some(s) => {
let parts = vec![ let parts = vec![
@@ -328,29 +379,58 @@ fn compute_claude_code_settings_fingerprint(settings: Option<&ClaudeCodeSettings
]; ];
sha256_hex(&parts.join("|")) 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. /// Returns a JSON string of the settings to be written to ~/.claude/settings.json.
fn build_claude_code_settings_json(settings: &ClaudeCodeSettings) -> Option<String> { /// 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<String> {
let mut map = serde_json::Map::new(); let mut map = serde_json::Map::new();
if let Some(ref tui) = settings.tui_mode { if let Some(s) = settings {
if let Some(ref tui) = s.tui_mode {
map.insert("tui".to_string(), serde_json::json!(tui)); map.insert("tui".to_string(), serde_json::json!(tui));
} }
if let Some(ref effort) = settings.effort { if let Some(ref effort) = s.effort {
map.insert("effort".to_string(), serde_json::json!(effort)); map.insert("effort".to_string(), serde_json::json!(effort));
} }
if settings.auto_scroll_disabled { if s.auto_scroll_disabled {
map.insert("autoScrollEnabled".to_string(), serde_json::json!(false)); map.insert("autoScrollEnabled".to_string(), serde_json::json!(false));
} }
if settings.focus_mode { if s.focus_mode {
map.insert("focusMode".to_string(), serde_json::json!(true)); map.insert("focusMode".to_string(), serde_json::json!(true));
} }
if settings.show_thinking_summaries { if s.show_thinking_summaries {
map.insert("showThinkingSummaries".to_string(), serde_json::json!(true)); 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() { if map.is_empty() {
None None
@@ -586,6 +666,13 @@ pub async fn create_container(
if bedrock.disable_prompt_caching { if bedrock.disable_prompt_caching {
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string()); 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.claude_instructions.as_deref(),
&project.port_mappings, &project.port_mappings,
project.mission_control_enabled, project.mission_control_enabled,
project.sandbox_mode_enabled,
); );
if let Some(ref instructions) = combined_instructions { if let Some(ref instructions) = combined_instructions {
@@ -683,11 +771,16 @@ pub async fn create_container(
if cc.prompt_caching_1h { if cc.prompt_caching_1h {
env_vars.push("ENABLE_PROMPT_CACHING_1H=1".to_string()); 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<Mount> = Vec::new(); let mut mounts: Vec<Mount> = 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.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.custom-env-fingerprint".to_string(), custom_env_fingerprint.clone());
labels.insert("triple-c.claude-code-settings-fingerprint".to_string(), 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(), labels.insert("triple-c.instructions-fingerprint".to_string(),
combined_instructions.as_ref().map(|s| sha256_hex(s)).unwrap_or_default()); 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()); 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.claude_instructions.as_deref(),
&project.port_mappings, &project.port_mappings,
project.mission_control_enabled, 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 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(); 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, global_claude_code_settings,
project.claude_code_settings.as_ref(), 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(); let container_cc_fp = get_label("triple-c.claude-code-settings-fingerprint").unwrap_or_default();
if container_cc_fp != expected_cc_fp { if container_cc_fp != expected_cc_fp {
log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp); log::info!("Claude Code settings mismatch (container={:?}, expected={:?})", container_cc_fp, expected_cc_fp);

View File

@@ -73,6 +73,8 @@ pub struct Project {
pub openai_compatible_config: Option<OpenAiCompatibleConfig>, pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool, pub allow_docker_access: bool,
#[serde(default)] #[serde(default)]
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool, pub mission_control_enabled: bool,
#[serde(default = "default_full_permissions")] #[serde(default = "default_full_permissions")]
pub full_permissions: bool, pub full_permissions: bool,
@@ -159,6 +161,10 @@ pub struct BedrockConfig {
pub aws_bearer_token: Option<String>, pub aws_bearer_token: Option<String>,
pub model_id: Option<String>, pub model_id: Option<String>,
pub disable_prompt_caching: bool, 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<String>,
} }
/// Ollama configuration for a project. /// Ollama configuration for a project.
@@ -199,6 +205,7 @@ impl Project {
ollama_config: None, ollama_config: None,
openai_compatible_config: None, openai_compatible_config: None,
allow_docker_access: false, allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false, mission_control_enabled: false,
full_permissions: false, full_permissions: false,
ssh_key_path: None, ssh_key_path: None,

View File

@@ -60,6 +60,7 @@ export default function ProjectCard({ project }: Props) {
const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? ""); const [bedrockProfile, setBedrockProfile] = useState(project.bedrock_config?.aws_profile ?? "");
const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? ""); const [bedrockBearerToken, setBedrockBearerToken] = useState(project.bedrock_config?.aws_bearer_token ?? "");
const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? ""); const [bedrockModelId, setBedrockModelId] = useState(project.bedrock_config?.model_id ?? "");
const [bedrockServiceTier, setBedrockServiceTier] = useState(project.bedrock_config?.service_tier ?? "");
// Ollama local state // Ollama local state
const [ollamaBaseUrl, setOllamaBaseUrl] = useState(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); 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 ?? ""); setBedrockProfile(project.bedrock_config?.aws_profile ?? "");
setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? ""); setBedrockBearerToken(project.bedrock_config?.aws_bearer_token ?? "");
setBedrockModelId(project.bedrock_config?.model_id ?? ""); setBedrockModelId(project.bedrock_config?.model_id ?? "");
setBedrockServiceTier(project.bedrock_config?.service_tier ?? "");
setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434"); setOllamaBaseUrl(project.ollama_config?.base_url ?? "http://host.docker.internal:11434");
setOllamaModelId(project.ollama_config?.model_id ?? ""); setOllamaModelId(project.ollama_config?.model_id ?? "");
setOpenaiCompatibleBaseUrl(project.openai_compatible_config?.base_url ?? "http://host.docker.internal:4000"); 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, aws_bearer_token: null,
model_id: null, model_id: null,
disable_prompt_caching: false, disable_prompt_caching: false,
service_tier: null,
}; };
const defaultOllamaConfig: OllamaConfig = { 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 () => { const handleOllamaBaseUrlBlur = async () => {
try { try {
const current = project.ollama_config ?? defaultOllamaConfig; const current = project.ollama_config ?? defaultOllamaConfig;
@@ -692,6 +705,28 @@ export default function ProjectCard({ project }: Props) {
</button> </button>
</div> </div>
{/* Sandbox mode toggle */}
<div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Sandbox mode<Tooltip text="Enables Claude Code's bash sandbox (bubblewrap-based filesystem and network isolation). Triple-C is the source of truth for the on/off state — toggling this overrides any manual /sandbox configuration in the container's settings.json on next start. Uses enableWeakerNestedSandbox since the container runs without privileged user namespaces." /></label>
<button
onClick={async () => {
try {
await update({ ...project, sandbox_mode_enabled: !project.sandbox_mode_enabled });
} catch (err) {
console.error("Failed to update sandbox mode setting:", err);
}
}}
disabled={!isStopped}
className={`px-2 py-0.5 text-xs rounded transition-colors disabled:opacity-50 ${
project.sandbox_mode_enabled
? "bg-[var(--success)] text-white"
: "bg-[var(--bg-primary)] border border-[var(--border-color)] text-[var(--text-secondary)]"
}`}
>
{project.sandbox_mode_enabled ? "ON" : "OFF"}
</button>
</div>
{/* Mission Control toggle */} {/* Mission Control toggle */}
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label> <label className="text-xs text-[var(--text-secondary)]">Mission Control<Tooltip text="Enables a web dashboard for monitoring and managing Claude sessions remotely." /></label>
@@ -953,6 +988,19 @@ export default function ProjectCard({ project }: Props) {
className={inputCls} className={inputCls}
/> />
</div> </div>
{/* Service tier */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Service Tier (optional)<Tooltip text="Sets ANTHROPIC_BEDROCK_SERVICE_TIER. Valid values are determined by AWS Bedrock (e.g. 'priority'). Leave blank for the account default." /></label>
<input
value={bedrockServiceTier}
onChange={(e) => setBedrockServiceTier(e.target.value)}
onBlur={handleBedrockServiceTierBlur}
placeholder="(default)"
disabled={!isStopped}
className={inputCls}
/>
</div>
</div> </div>
); );
})()} })()}

View File

@@ -25,6 +25,7 @@ export interface Project {
ollama_config: OllamaConfig | null; ollama_config: OllamaConfig | null;
openai_compatible_config: OpenAiCompatibleConfig | null; openai_compatible_config: OpenAiCompatibleConfig | null;
allow_docker_access: boolean; allow_docker_access: boolean;
sandbox_mode_enabled: boolean;
mission_control_enabled: boolean; mission_control_enabled: boolean;
full_permissions: boolean; full_permissions: boolean;
ssh_key_path: string | null; ssh_key_path: string | null;
@@ -61,6 +62,7 @@ export interface BedrockConfig {
aws_bearer_token: string | null; aws_bearer_token: string | null;
model_id: string | null; model_id: string | null;
disable_prompt_caching: boolean; disable_prompt_caching: boolean;
service_tier: string | null;
} }
export interface OllamaConfig { export interface OllamaConfig {

View File

@@ -31,6 +31,8 @@ RUN for i in 1 2 3 4 5; do \
pkg-config \ pkg-config \
libssl-dev \ libssl-dev \
cron \ cron \
bubblewrap \
socat \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Remove default ubuntu user to free UID 1000 for host-user remapping # Remove default ubuntu user to free UID 1000 for host-user remapping