From 0f803e2ee407f9b7f2240e086eaadca2a2d1d91b Mon Sep 17 00:00:00 2001 From: Ani Date: Mon, 11 May 2026 06:54:32 +0000 Subject: [PATCH] Add host-protection hooks for Claude Code and Copilot CLI PreToolUse hooks that block bash/shell tool calls when a .devcontainer/devcontainer.json exists, forcing agents to use devcontainer-mcp MCP tools instead of executing on the host. - hooks/devcontainer-guard.sh: shared hook script supporting both Claude Code and Copilot CLI payload formats - .github/hooks/devcontainer-guard.json: Copilot CLI hook config for this repo - install.sh/install.ps1: auto-install hooks and configure agent environments during installation - 10 integration tests validating block/allow/bypass behavior - SKILL.md footer updated to mention hook enforcement Bypass: USER_CONFIRMED_HOST_OPERATION=1 in the command allows through, designed as a semantic tripwire that LLMs cannot honestly generate. --- .github/hooks/devcontainer-guard.json | 12 ++ SKILL.md | 2 + crates/devcontainer-mcp-core/src/file_ops.rs | 5 +- .../tests/hook_guard_test.rs | 172 ++++++++++++++++++ hooks/devcontainer-guard.sh | 72 ++++++++ install.ps1 | 92 ++++++++++ install.sh | 110 +++++++++++ skills/footer.md | 2 + 8 files changed, 463 insertions(+), 4 deletions(-) create mode 100644 .github/hooks/devcontainer-guard.json create mode 100644 crates/devcontainer-mcp-core/tests/hook_guard_test.rs create mode 100755 hooks/devcontainer-guard.sh diff --git a/.github/hooks/devcontainer-guard.json b/.github/hooks/devcontainer-guard.json new file mode 100644 index 0000000..24c6fa2 --- /dev/null +++ b/.github/hooks/devcontainer-guard.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "./hooks/devcontainer-guard.sh", + "timeoutSec": 5 + } + ] + } +} diff --git a/SKILL.md b/SKILL.md index 367de62..9e09880 100644 --- a/SKILL.md +++ b/SKILL.md @@ -185,6 +185,8 @@ If `devpod_up`, `devcontainer_up`, or `codespaces_create` returns errors: - ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything - ✅ DO check `.devcontainer/devcontainer.json` first +> **Note:** Host-protection hooks are installed for supported agent environments (Claude Code, GitHub Copilot CLI) that automatically block shell commands when a devcontainer is detected. If a command is blocked, use the appropriate MCP tool instead. + ## File Operations **All backends support built-in file operations — no need to construct shell commands.** diff --git a/crates/devcontainer-mcp-core/src/file_ops.rs b/crates/devcontainer-mcp-core/src/file_ops.rs index 0efaaab..c79ccee 100644 --- a/crates/devcontainer-mcp-core/src/file_ops.rs +++ b/crates/devcontainer-mcp-core/src/file_ops.rs @@ -75,10 +75,7 @@ pub fn write_file_command(path: &str, content: &str) -> String { /// Build a shell command that lists a directory (non-hidden, up to 2 levels). pub fn list_dir_command(path: &str) -> String { - format!( - "find {} -maxdepth 2 -not -path '*/.*' | sort", - quote(path) - ) + format!("find {} -maxdepth 2 -not -path '*/.*' | sort", quote(path)) } #[cfg(test)] diff --git a/crates/devcontainer-mcp-core/tests/hook_guard_test.rs b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs new file mode 100644 index 0000000..cb30138 --- /dev/null +++ b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs @@ -0,0 +1,172 @@ +//! Integration tests for the devcontainer-guard.sh hook script. +//! +//! These tests verify that the hook correctly blocks/allows tool calls +//! based on the agent format, tool name, devcontainer presence, and bypass. + +use std::process::Command; + +fn repo_root() -> std::path::PathBuf { + let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")); + manifest + .parent() + .and_then(|p| p.parent()) + .expect("could not find repo root") + .to_path_buf() +} + +fn hook_path() -> std::path::PathBuf { + repo_root().join("hooks/devcontainer-guard.sh") +} + +/// Run the hook script with the given JSON input and return (stdout, exit_code). +fn run_hook(json_input: &str) -> (String, i32) { + let output = Command::new("bash") + .arg(hook_path()) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + .and_then(|mut child| { + use std::io::Write; + child + .stdin + .take() + .unwrap() + .write_all(json_input.as_bytes()) + .unwrap(); + child.wait_with_output() + }) + .expect("failed to run hook script"); + + let stdout = String::from_utf8_lossy(&output.stdout).to_string(); + let code = output.status.code().unwrap_or(-1); + (stdout, code) +} + +fn cwd_with_devcontainer() -> String { + repo_root().to_string_lossy().to_string() +} + +// ----------------------------------------------------------------------- +// Copilot CLI format +// ----------------------------------------------------------------------- + +#[test] +fn copilot_cli_bash_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"bash","toolArgs":{{"command":"cargo build"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!( + stdout.contains(r#""permissionDecision":"deny""#) + || stdout.contains(r#""permissionDecision": "deny""#) + ); + // Copilot CLI format should NOT have hookSpecificOutput + assert!(!stdout.contains("hookSpecificOutput")); +} + +#[test] +fn copilot_cli_shell_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"shell","toolArgs":{{"command":"make"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, _) = run_hook(&input); + assert!(stdout.contains("deny")); +} + +#[test] +fn copilot_cli_powershell_with_devcontainer_denies() { + let input = format!( + r#"{{"toolName":"powershell","toolArgs":{{"command":"dir"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, _) = run_hook(&input); + assert!(stdout.contains("deny")); +} + +#[test] +fn copilot_cli_view_tool_allows() { + let input = format!( + r#"{{"toolName":"view","toolArgs":{{"path":"src/main.rs"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "non-bash tool should produce no output"); +} + +#[test] +fn copilot_cli_no_devcontainer_allows() { + let input = r#"{"toolName":"bash","toolArgs":{"command":"ls"},"cwd":"/tmp"}"#; + let (stdout, code) = run_hook(input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +// ----------------------------------------------------------------------- +// Claude Code format +// ----------------------------------------------------------------------- + +#[test] +fn claude_code_bash_with_devcontainer_denies() { + let input = format!( + r#"{{"tool_name":"Bash","tool_input":{{"command":"npm install"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.contains("hookSpecificOutput")); + assert!(stdout.contains("deny")); +} + +#[test] +fn claude_code_edit_tool_allows() { + let input = format!( + r#"{{"tool_name":"Edit","tool_input":{{"path":"src/main.rs"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +// ----------------------------------------------------------------------- +// Bypass +// ----------------------------------------------------------------------- + +#[test] +fn bypass_string_allows_through() { + let input = format!( + r#"{{"tool_name":"Bash","tool_input":{{"command":"USER_CONFIRMED_HOST_OPERATION=1 cargo build"}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty(), "bypass should produce no output"); +} + +// ----------------------------------------------------------------------- +// Edge cases +// ----------------------------------------------------------------------- + +#[test] +fn missing_cwd_allows() { + let input = r#"{"toolName":"bash","toolArgs":{"command":"ls"}}"#; + let (stdout, code) = run_hook(input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} + +#[test] +fn empty_tool_name_allows() { + let input = format!( + r#"{{"toolName":"","toolArgs":{{}},"cwd":"{}"}}"#, + cwd_with_devcontainer() + ); + let (stdout, code) = run_hook(&input); + assert_eq!(code, 0); + assert!(stdout.is_empty()); +} diff --git a/hooks/devcontainer-guard.sh b/hooks/devcontainer-guard.sh new file mode 100755 index 0000000..fb78b19 --- /dev/null +++ b/hooks/devcontainer-guard.sh @@ -0,0 +1,72 @@ +#!/usr/bin/env bash +# devcontainer-guard.sh — PreToolUse hook for Claude Code & GitHub Copilot CLI +# +# Blocks bash/shell tool calls when .devcontainer/devcontainer.json exists in +# the working directory, forcing agents to use devcontainer-mcp MCP tools +# instead of running commands directly on the host. +# +# Read-only tools (view, grep, glob) and file edits are allowed through — only +# command execution is blocked. +# +# Bypass: include USER_CONFIRMED_HOST_OPERATION=1 in the command. +# +# Supports both agent payload formats: +# Claude Code: { tool_name, tool_input, cwd, ... } +# Copilot CLI: { toolName, toolArgs, cwd, ... } + +set -euo pipefail + +INPUT=$(cat) + +# --- Detect agent format and extract fields --- + +# Try Claude Code fields first (snake_case), fall back to Copilot CLI (camelCase) +TOOL_NAME=$(echo "$INPUT" | jq -r '.tool_name // .toolName // empty') +CWD=$(echo "$INPUT" | jq -r '.cwd // empty') + +# Only guard bash/shell tool calls — allow everything else through +case "$TOOL_NAME" in + Bash|bash|shell|powershell|Shell|PowerShell) ;; + *) exit 0 ;; +esac + +TOOL_INPUT=$(echo "$INPUT" | jq -r '(.tool_input // .toolArgs // {}) | tostring') + +# Check for the bypass string anywhere in the tool input +if echo "$TOOL_INPUT" | grep -q 'USER_CONFIRMED_HOST_OPERATION=1'; then + exit 0 +fi + +# Check if a devcontainer exists in the working directory +if [ -z "$CWD" ]; then + # No cwd in payload — can't determine context, allow through + exit 0 +fi + +if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then + # No devcontainer — allow through + exit 0 +fi + +# --- Devcontainer exists: block the tool call --- + +DENY_REASON="Host execution blocked. This project has a devcontainer. Use devcontainer-mcp tools (devcontainer_exec, devpod_ssh, codespaces_ssh, and file operation tools) instead of running commands directly on the host." + +# Detect which agent format to use for the response +if echo "$INPUT" | jq -e '.tool_name // empty' >/dev/null 2>&1 && \ + [ -n "$(echo "$INPUT" | jq -r '.tool_name // empty')" ]; then + # Claude Code format + jq -n --arg reason "$DENY_REASON" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' +else + # Copilot CLI format + jq -n --arg reason "$DENY_REASON" '{ + permissionDecision: "deny", + permissionDecisionReason: $reason + }' +fi diff --git a/install.ps1 b/install.ps1 index 9f44397..8e61b7c 100644 --- a/install.ps1 +++ b/install.ps1 @@ -123,6 +123,98 @@ foreach ($dir in $skillDirs) { } } +# --------------------------------------------------------------------------- +# 3b. Install host-protection hooks +# --------------------------------------------------------------------------- + +Write-Step "Installing host-protection hook..." + +$hookUrl = "https://raw.githubusercontent.com/$Repo/main/hooks/devcontainer-guard.sh" +$WslHookDir = "~/.local/share/devcontainer-mcp/hooks" +$WslHookPath = "$WslHookDir/devcontainer-guard.sh" + +$hookResult = wsl -d $WslDistro bash -c "mkdir -p '$WslHookDir' && curl -fsSL -o '$WslHookPath' '$hookUrl' && chmod +x '$WslHookPath' && echo OK" 2>&1 +if ($hookResult -match "OK") { + Write-Ok "Hook script installed in WSL at $WslHookPath" +} else { + Write-Warn "Could not install hook script in WSL" +} + +# Configure Claude Code PreToolUse hook (Windows-side) +Write-Step "Configuring agent host-protection hooks..." + +$claudeSettings = "$env:USERPROFILE\.claude\settings.json" +try { + $hookEntry = @{ + matcher = "Bash" + hooks = @( + @{ + type = "command" + command = "wsl $WslHookPath" + timeout = 5 + } + ) + } + + if (Test-Path $claudeSettings) { + $content = Get-Content -Raw $claudeSettings | ConvertFrom-Json + if (-not $content.hooks) { + $content | Add-Member -NotePropertyName "hooks" -NotePropertyValue ([PSCustomObject]@{}) + } + if (-not $content.hooks.PreToolUse) { + $content.hooks | Add-Member -NotePropertyName "PreToolUse" -NotePropertyValue @() + } + # Check if already configured + $already = $false + foreach ($group in $content.hooks.PreToolUse) { + foreach ($h in $group.hooks) { + if ($h.command -match "devcontainer-guard") { $already = $true; break } + } + } + if (-not $already) { + $content.hooks.PreToolUse += [PSCustomObject]$hookEntry + $content | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8 + Write-Ok "Claude Code — added hook to $claudeSettings" + } else { + Write-Ok "Claude Code — hook already configured" + } + } else { + $dir = Split-Path $claudeSettings -Parent + if ($dir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $config = [PSCustomObject]@{ + hooks = [PSCustomObject]@{ + PreToolUse = @([PSCustomObject]$hookEntry) + } + } + $config | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8 + Write-Ok "Claude Code — created $claudeSettings" + } +} catch { + Write-Warn "Claude Code — could not configure hooks" +} + +# Configure Copilot CLI preToolUse hook (Windows-side) +$copilotHooksDir = "$env:USERPROFILE\.copilot\hooks" +try { + New-Item -ItemType Directory -Path $copilotHooksDir -Force | Out-Null + $copilotHook = [PSCustomObject]@{ + version = 1 + hooks = [PSCustomObject]@{ + preToolUse = @( + [PSCustomObject]@{ + type = "command" + bash = "wsl $WslHookPath" + timeoutSec = 5 + } + ) + } + } + $copilotHook | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8 + Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-guard.json" +} catch { + Write-Warn "Copilot CLI — could not configure hooks" +} + # --------------------------------------------------------------------------- # 4. Detect backend CLIs available in WSL # --------------------------------------------------------------------------- diff --git a/install.sh b/install.sh index b3db7cf..13d07ce 100755 --- a/install.sh +++ b/install.sh @@ -116,6 +116,116 @@ for dir in "${SKILL_DIRS[@]}"; do echo " ${dir}/SKILL.md" || true done +# --------------------------------------------------------------------------- +# Install host-protection hooks +# --------------------------------------------------------------------------- + +HOOK_URL="https://raw.githubusercontent.com/${REPO}/main/hooks/devcontainer-guard.sh" +HOOK_DIR="${HOME}/.local/share/devcontainer-mcp/hooks" +HOOK_PATH="${HOOK_DIR}/devcontainer-guard.sh" + +echo "" +echo "==> Installing host-protection hook..." +mkdir -p "$HOOK_DIR" +curl -fsSL -o "$HOOK_PATH" "$HOOK_URL" 2>/dev/null && \ + chmod +x "$HOOK_PATH" && \ + echo " ${HOOK_PATH}" || echo " ⚠ Could not download hook script" + +# Configure Claude Code PreToolUse hook +configure_claude_hook() { + local settings_file="${HOME}/.claude/settings.json" + local hook_command="$HOOK_PATH" + + if [ ! -f "$settings_file" ]; then + mkdir -p "$(dirname "$settings_file")" + cat > "$settings_file" << CLAUDEEOF +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "${hook_command}", + "timeout": 5 + } + ] + } + ] + } +} +CLAUDEEOF + echo " ✓ Claude Code — created ${settings_file}" + elif command -v python3 >/dev/null 2>&1; then + python3 -c " +import json, sys + +path = '${settings_file}' +hook_cmd = '${hook_command}' + +with open(path) as f: + data = json.load(f) + +hooks = data.setdefault('hooks', {}) +pre_tool = hooks.setdefault('PreToolUse', []) + +# Check if devcontainer-guard is already configured +already = False +for group in pre_tool: + for h in group.get('hooks', []): + if 'devcontainer-guard' in h.get('command', ''): + already = True + break + +if not already: + pre_tool.append({ + 'matcher': 'Bash', + 'hooks': [{ + 'type': 'command', + 'command': hook_cmd, + 'timeout': 5 + }] + }) + with open(path, 'w') as f: + json.dump(data, f, indent=2) + print(' ✓ Claude Code — added hook to ${settings_file}') +else: + print(' ✓ Claude Code — hook already configured') +" 2>/dev/null || echo " ⚠ Claude Code — could not update ${settings_file}" + else + echo " ⚠ Claude Code — exists but python3 not available to merge" + fi +} + +# Configure Copilot CLI preToolUse hook +configure_copilot_hook() { + local hooks_dir="${HOME}/.copilot/hooks" + local hooks_file="${hooks_dir}/devcontainer-guard.json" + + mkdir -p "$hooks_dir" + cat > "$hooks_file" << COPILOTEOF +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "type": "command", + "bash": "${HOOK_PATH}", + "timeoutSec": 5 + } + ] + } +} +COPILOTEOF + echo " ✓ Copilot CLI — created ${hooks_file}" +} + +echo "" +echo "==> Configuring agent host-protection hooks..." +configure_claude_hook +configure_copilot_hook + # Detect available backends echo "" echo "Backend CLIs detected (install as needed — MCP server gives helpful errors if missing):" diff --git a/skills/footer.md b/skills/footer.md index 285c808..24a0570 100644 --- a/skills/footer.md +++ b/skills/footer.md @@ -12,3 +12,5 @@ order: 90 - ✅ DO ask the user which account/machine type to use - ✅ DO use `devpod_ssh`, `devcontainer_exec`, or `codespaces_ssh` for everything - ✅ DO check `.devcontainer/devcontainer.json` first + +> **Note:** Host-protection hooks are installed for supported agent environments (Claude Code, GitHub Copilot CLI) that automatically block shell commands when a devcontainer is detected. If a command is blocked, use the appropriate MCP tool instead.