Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .github/hooks/devcontainer-guard.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"hooks": {
"preToolUse": [
{
"type": "command",
"bash": "./hooks/devcontainer-guard.sh",
"timeoutSec": 5
}
]
}
}
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.**
Expand Down
5 changes: 1 addition & 4 deletions crates/devcontainer-mcp-core/src/file_ops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
172 changes: 172 additions & 0 deletions crates/devcontainer-mcp-core/tests/hook_guard_test.rs
Original file line number Diff line number Diff line change
@@ -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());
}
72 changes: 72 additions & 0 deletions hooks/devcontainer-guard.sh
Original file line number Diff line number Diff line change
@@ -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
92 changes: 92 additions & 0 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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
# ---------------------------------------------------------------------------
Expand Down
Loading