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
2 changes: 1 addition & 1 deletion .github/hooks/devcontainer-guard.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"preToolUse": [
{
"type": "command",
"bash": "./hooks/devcontainer-guard.sh",
"bash": "./.github/hooks/devcontainer-guard.sh",
"timeoutSec": 5
}
]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,9 @@
# Read-only tools (view, grep, glob) and file edits are allowed through — only
# command execution is blocked.
#
# Host-safe commands (git, gh, curl, etc.) are allowlisted and always permitted
# since they operate on the repo/host, not the project's build environment.
#
# Bypass: include USER_CONFIRMED_HOST_OPERATION=1 in the command.
#
# Supports both agent payload formats:
Expand Down Expand Up @@ -48,7 +51,61 @@ if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
exit 0
fi

# --- Devcontainer exists: block the tool call ---
# --- Devcontainer exists: check allowlist before blocking ---

# Extract the command string from tool input (handles both formats)
COMMAND=$(echo "$INPUT" | jq -r '(.tool_input.command // .toolArgs.command // "") | tostring')

# Commands that are safe to run on the host even when a devcontainer exists.
# These operate on the repo/host itself, not on the project's build environment.
ALLOWED_HOST_COMMANDS=(
git
gh
)

# Extract all meaningful commands from a shell string, skipping env vars
# (KEY=VALUE) and cd/pushd/popd. Splits on &&, ||, ;, and | to catch every
# command in a chain or pipeline.
all_commands() {
local cmd="$1"
while IFS= read -r segment; do
segment="${segment#"${segment%%[![:space:]]*}"}"
[ -z "$segment" ] && continue
for token in $segment; do
if [[ "$token" == *=* && "$token" != -* ]]; then
continue
fi
case "$token" in
cd|pushd|popd) break ;;
esac
basename "$token"
break
done
done < <(echo "$cmd" | sed 's/ *&& */\n/g; s/ *|| */\n/g; s/ *; */\n/g; s/ *| */\n/g')
}

# Every command in the chain must be on the allowlist
ALL_ALLOWED=true
while IFS= read -r cmd_name; do
[ -z "$cmd_name" ] && continue
FOUND=false
for allowed in "${ALLOWED_HOST_COMMANDS[@]}"; do
if [ "$cmd_name" = "$allowed" ]; then
FOUND=true
break
fi
done
if [ "$FOUND" = false ]; then
ALL_ALLOWED=false
break
fi
done < <(all_commands "$COMMAND")

if [ "$ALL_ALLOWED" = true ] && [ -n "$(all_commands "$COMMAND")" ]; then
exit 0
fi

# --- Not on the allowlist: 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."

Expand Down
12 changes: 12 additions & 0 deletions .github/hooks/devcontainer-skill-loader.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"version": 1,
"hooks": {
"sessionStart": [
{
"type": "command",
"bash": "./.github/hooks/devcontainer-skill-loader.sh",
"timeoutSec": 5
}
]
}
}
49 changes: 49 additions & 0 deletions .github/hooks/devcontainer-skill-loader.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
#!/usr/bin/env bash
# devcontainer-skill-loader.sh — SessionStart hook for Claude Code & Copilot CLI
#
# When a session starts in a directory with .devcontainer/devcontainer.json,
# injects the devcontainer-mcp SKILL.md content as additionalContext so the
# agent automatically knows how to use devcontainer-mcp tools.
#
# Supports both agent payload formats:
# Claude Code: { tool_name, tool_input, cwd, ... }
# Copilot CLI: { toolName, toolArgs, cwd, ... }

set -euo pipefail

INPUT=$(cat)

# Extract working directory from the payload
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')

if [ -z "$CWD" ]; then
exit 0
fi

if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
exit 0
fi

# Look for SKILL.md in order of preference
SKILL_PATH=""
SEARCH_PATHS=(
"${HOME}/.local/share/devcontainer-mcp/SKILL.md"
"${HOME}/.copilot/skills/devcontainer-mcp/SKILL.md"
"${HOME}/.claude/skills/devcontainer-mcp/SKILL.md"
"${HOME}/.agents/skills/devcontainer-mcp/SKILL.md"
)

for p in "${SEARCH_PATHS[@]}"; do
if [ -f "$p" ]; then
SKILL_PATH="$p"
break
fi
done

if [ -z "$SKILL_PATH" ]; then
exit 0
fi

SKILL_CONTENT=$(cat "$SKILL_PATH")

jq -n --arg ctx "$SKILL_CONTENT" '{ "additionalContext": $ctx }'
2 changes: 1 addition & 1 deletion crates/devcontainer-mcp-core/tests/hook_guard_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ fn repo_root() -> std::path::PathBuf {
}

fn hook_path() -> std::path::PathBuf {
repo_root().join("hooks/devcontainer-guard.sh")
repo_root().join(".github/hooks/devcontainer-guard.sh")
}

/// Run the hook script with the given JSON input and return (stdout, exit_code).
Expand Down
94 changes: 76 additions & 18 deletions install.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -129,23 +129,36 @@ foreach ($dir in $skillDirs) {

Write-Step "Installing host-protection hook..."

$hookUrl = "https://raw.githubusercontent.com/$Repo/main/hooks/devcontainer-guard.sh"
$hookUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-guard.sh"
$loaderUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-skill-loader.sh"
$WslHookDir = "~/.local/share/devcontainer-mcp/hooks"
$WslHookPath = "$WslHookDir/devcontainer-guard.sh"
$WslLoaderPath = "$WslHookDir/devcontainer-skill-loader.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"
Write-Ok "Guard hook installed in WSL at $WslHookPath"
} else {
Write-Warn "Could not install hook script in WSL"
Write-Warn "Could not install guard hook in WSL"
}

# Configure Claude Code PreToolUse hook (Windows-side)
Write-Step "Configuring agent host-protection hooks..."
$loaderResult = wsl -d $WslDistro bash -c "curl -fsSL -o '$WslLoaderPath' '$loaderUrl' && chmod +x '$WslLoaderPath' && echo OK" 2>&1
if ($loaderResult -match "OK") {
Write-Ok "Skill-loader hook installed in WSL at $WslLoaderPath"
} else {
Write-Warn "Could not install skill-loader hook in WSL"
}

# Install SKILL.md alongside hooks for the loader to find
$WslSkillDataPath = "~/.local/share/devcontainer-mcp/SKILL.md"
wsl -d $WslDistro bash -c "curl -fsSL -o '$WslSkillDataPath' '$skillUrl'" 2>&1 | Out-Null

# Configure Claude Code PreToolUse + SessionStart hooks (Windows-side)
Write-Step "Configuring agent hooks..."

$claudeSettings = "$env:USERPROFILE\.claude\settings.json"
try {
$hookEntry = @{
$guardEntry = @{
matcher = "Bash"
hooks = @(
@{
Expand All @@ -155,35 +168,64 @@ try {
}
)
}
$loaderEntry = @{
hooks = @(
@{
type = "command"
command = "wsl $WslLoaderPath"
timeout = 5
}
)
}

if (Test-Path $claudeSettings) {
$content = Get-Content -Raw $claudeSettings | ConvertFrom-Json
if (-not $content.hooks) {
$content | Add-Member -NotePropertyName "hooks" -NotePropertyValue ([PSCustomObject]@{})
}

# PreToolUse: devcontainer-guard
if (-not $content.hooks.PreToolUse) {
$content.hooks | Add-Member -NotePropertyName "PreToolUse" -NotePropertyValue @()
}
# Check if already configured
$already = $false
$alreadyGuard = $false
foreach ($group in $content.hooks.PreToolUse) {
foreach ($h in $group.hooks) {
if ($h.command -match "devcontainer-guard") { $already = $true; break }
if ($h.command -match "devcontainer-guard") { $alreadyGuard = $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"
if (-not $alreadyGuard) {
$content.hooks.PreToolUse += [PSCustomObject]$guardEntry
Write-Ok "Claude Code — added PreToolUse hook"
} else {
Write-Ok "Claude Code — hook already configured"
Write-Ok "Claude Code — PreToolUse hook already configured"
}

# SessionStart: skill-loader
if (-not $content.hooks.SessionStart) {
$content.hooks | Add-Member -NotePropertyName "SessionStart" -NotePropertyValue @()
}
$alreadyLoader = $false
foreach ($group in $content.hooks.SessionStart) {
foreach ($h in $group.hooks) {
if ($h.command -match "skill-loader") { $alreadyLoader = $true; break }
}
}
if (-not $alreadyLoader) {
$content.hooks.SessionStart += [PSCustomObject]$loaderEntry
Write-Ok "Claude Code — added SessionStart hook"
} else {
Write-Ok "Claude Code — SessionStart hook already configured"
}

$content | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8
} else {
$dir = Split-Path $claudeSettings -Parent
if ($dir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
$config = [PSCustomObject]@{
hooks = [PSCustomObject]@{
PreToolUse = @([PSCustomObject]$hookEntry)
PreToolUse = @([PSCustomObject]$guardEntry)
SessionStart = @([PSCustomObject]$loaderEntry)
}
}
$config | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8
Expand All @@ -193,11 +235,12 @@ try {
Write-Warn "Claude Code — could not configure hooks"
}

# Configure Copilot CLI preToolUse hook (Windows-side)
# Configure Copilot CLI preToolUse + sessionStart hooks (Windows-side)
$copilotHooksDir = "$env:USERPROFILE\.copilot\hooks"
try {
New-Item -ItemType Directory -Path $copilotHooksDir -Force | Out-Null
$copilotHook = [PSCustomObject]@{

$copilotGuard = [PSCustomObject]@{
version = 1
hooks = [PSCustomObject]@{
preToolUse = @(
Expand All @@ -209,8 +252,23 @@ try {
)
}
}
$copilotHook | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8
$copilotGuard | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8
Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-guard.json"

$copilotLoader = [PSCustomObject]@{
version = 1
hooks = [PSCustomObject]@{
sessionStart = @(
[PSCustomObject]@{
type = "command"
bash = "wsl $WslLoaderPath"
timeoutSec = 5
}
)
}
}
$copilotLoader | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-skill-loader.json" -Encoding UTF8
Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-skill-loader.json"
} catch {
Write-Warn "Copilot CLI — could not configure hooks"
}
Expand Down
Loading