From b3ff47902aa73ff1bb89db18bcb79036383365b6 Mon Sep 17 00:00:00 2001 From: Ani Date: Thu, 14 May 2026 09:29:42 -0700 Subject: [PATCH 1/2] Add allowlist for host-safe commands in devcontainer guard hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The hook was blanket-blocking all bash/shell commands when a devcontainer was detected, including git operations that are inherently host-level. Add an allowlist of host-safe commands (git, gh) that are permitted even when a devcontainer exists. All commands in a chain (&&, ||, ;, |) must be on the allowlist — so 'git fetch && cargo build' is correctly blocked. Move hooks from hooks/ to .github/hooks/ so the project self-protects even without the MCP server installed. Commands like curl/wget are intentionally excluded since they can pipe to sh. The bypass (USER_CONFIRMED_HOST_OPERATION=1) still works for anything not on the allowlist. --- .github/hooks/devcontainer-guard.json | 2 +- .../hooks}/devcontainer-guard.sh | 59 ++++++++++++++++++- .../tests/hook_guard_test.rs | 2 +- install.ps1 | 2 +- install.sh | 2 +- 5 files changed, 62 insertions(+), 5 deletions(-) rename {hooks => .github/hooks}/devcontainer-guard.sh (57%) diff --git a/.github/hooks/devcontainer-guard.json b/.github/hooks/devcontainer-guard.json index 24c6fa2..a08ef1c 100644 --- a/.github/hooks/devcontainer-guard.json +++ b/.github/hooks/devcontainer-guard.json @@ -4,7 +4,7 @@ "preToolUse": [ { "type": "command", - "bash": "./hooks/devcontainer-guard.sh", + "bash": "./.github/hooks/devcontainer-guard.sh", "timeoutSec": 5 } ] diff --git a/hooks/devcontainer-guard.sh b/.github/hooks/devcontainer-guard.sh similarity index 57% rename from hooks/devcontainer-guard.sh rename to .github/hooks/devcontainer-guard.sh index fb78b19..430e308 100755 --- a/hooks/devcontainer-guard.sh +++ b/.github/hooks/devcontainer-guard.sh @@ -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: @@ -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." diff --git a/crates/devcontainer-mcp-core/tests/hook_guard_test.rs b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs index cb30138..e277bf2 100644 --- a/crates/devcontainer-mcp-core/tests/hook_guard_test.rs +++ b/crates/devcontainer-mcp-core/tests/hook_guard_test.rs @@ -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). diff --git a/install.ps1 b/install.ps1 index 8e61b7c..8a0cf6e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -129,7 +129,7 @@ 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" $WslHookDir = "~/.local/share/devcontainer-mcp/hooks" $WslHookPath = "$WslHookDir/devcontainer-guard.sh" diff --git a/install.sh b/install.sh index 13d07ce..684e373 100755 --- a/install.sh +++ b/install.sh @@ -120,7 +120,7 @@ done # Install host-protection hooks # --------------------------------------------------------------------------- -HOOK_URL="https://raw.githubusercontent.com/${REPO}/main/hooks/devcontainer-guard.sh" +HOOK_URL="https://raw.githubusercontent.com/${REPO}/main/.github/hooks/devcontainer-guard.sh" HOOK_DIR="${HOME}/.local/share/devcontainer-mcp/hooks" HOOK_PATH="${HOOK_DIR}/devcontainer-guard.sh" From 09eba3ff7f70b82ddfdea29e2899753bf0ab0473 Mon Sep 17 00:00:00 2001 From: Ani Date: Thu, 14 May 2026 13:05:47 -0700 Subject: [PATCH 2/2] Add sessionStart hook to auto-inject SKILL.md on session start When a session starts in a directory with .devcontainer/devcontainer.json, the new devcontainer-skill-loader hook injects the SKILL.md content as additionalContext, making agents automatically aware of devcontainer-mcp tools without manual skill installation. - Add .github/hooks/devcontainer-skill-loader.sh and JSON config - Install SKILL.md to ~/.local/share/devcontainer-mcp/ for the loader - Configure sessionStart hooks for both Claude Code and Copilot CLI - Update install.sh and install.ps1 with the new hook Closes #19 --- .github/hooks/devcontainer-skill-loader.json | 12 +++ .github/hooks/devcontainer-skill-loader.sh | 49 +++++++++++ install.ps1 | 92 ++++++++++++++++---- install.sh | 92 +++++++++++++++++--- 4 files changed, 214 insertions(+), 31 deletions(-) create mode 100644 .github/hooks/devcontainer-skill-loader.json create mode 100755 .github/hooks/devcontainer-skill-loader.sh diff --git a/.github/hooks/devcontainer-skill-loader.json b/.github/hooks/devcontainer-skill-loader.json new file mode 100644 index 0000000..1a6a8ce --- /dev/null +++ b/.github/hooks/devcontainer-skill-loader.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "./.github/hooks/devcontainer-skill-loader.sh", + "timeoutSec": 5 + } + ] + } +} diff --git a/.github/hooks/devcontainer-skill-loader.sh b/.github/hooks/devcontainer-skill-loader.sh new file mode 100755 index 0000000..5dcfbc9 --- /dev/null +++ b/.github/hooks/devcontainer-skill-loader.sh @@ -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 }' diff --git a/install.ps1 b/install.ps1 index 8a0cf6e..f71da2e 100644 --- a/install.ps1 +++ b/install.ps1 @@ -130,22 +130,35 @@ foreach ($dir in $skillDirs) { Write-Step "Installing host-protection hook..." $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 = @( @{ @@ -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 @@ -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 = @( @@ -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" } diff --git a/install.sh b/install.sh index 684e373..d6f8fc2 100755 --- a/install.sh +++ b/install.sh @@ -121,8 +121,10 @@ done # --------------------------------------------------------------------------- HOOK_URL="https://raw.githubusercontent.com/${REPO}/main/.github/hooks/devcontainer-guard.sh" +LOADER_URL="https://raw.githubusercontent.com/${REPO}/main/.github/hooks/devcontainer-skill-loader.sh" HOOK_DIR="${HOME}/.local/share/devcontainer-mcp/hooks" HOOK_PATH="${HOOK_DIR}/devcontainer-guard.sh" +LOADER_PATH="${HOOK_DIR}/devcontainer-skill-loader.sh" echo "" echo "==> Installing host-protection hook..." @@ -131,10 +133,22 @@ 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 +echo "" +echo "==> Installing skill-loader hook..." +curl -fsSL -o "$LOADER_PATH" "$LOADER_URL" 2>/dev/null && \ + chmod +x "$LOADER_PATH" && \ + echo " ${LOADER_PATH}" || echo " ⚠ Could not download skill-loader hook" + +# Install SKILL.md alongside hooks for the loader to find +SKILL_DATA_PATH="${HOME}/.local/share/devcontainer-mcp/SKILL.md" +curl -fsSL -o "$SKILL_DATA_PATH" "$SKILL_URL" 2>/dev/null && \ + echo " ${SKILL_DATA_PATH}" || true + +# Configure Claude Code PreToolUse + SessionStart hooks configure_claude_hook() { local settings_file="${HOME}/.claude/settings.json" local hook_command="$HOOK_PATH" + local loader_command="$LOADER_PATH" if [ ! -f "$settings_file" ]; then mkdir -p "$(dirname "$settings_file")" @@ -152,6 +166,17 @@ configure_claude_hook() { } ] } + ], + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "${loader_command}", + "timeout": 5 + } + ] + } ] } } @@ -163,22 +188,23 @@ import json, sys path = '${settings_file}' hook_cmd = '${hook_command}' +loader_cmd = '${loader_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 +# --- PreToolUse: devcontainer-guard --- +pre_tool = hooks.setdefault('PreToolUse', []) +already_guard = False for group in pre_tool: for h in group.get('hooks', []): if 'devcontainer-guard' in h.get('command', ''): - already = True + already_guard = True break -if not already: +if not already_guard: pre_tool.append({ 'matcher': 'Bash', 'hooks': [{ @@ -187,24 +213,46 @@ if not already: 'timeout': 5 }] }) - with open(path, 'w') as f: - json.dump(data, f, indent=2) - print(' ✓ Claude Code — added hook to ${settings_file}') + print(' ✓ Claude Code — added PreToolUse hook to ${settings_file}') else: - print(' ✓ Claude Code — hook already configured') + print(' ✓ Claude Code — PreToolUse hook already configured') + +# --- SessionStart: skill-loader --- +session_start = hooks.setdefault('SessionStart', []) +already_loader = False +for group in session_start: + for h in group.get('hooks', []): + if 'skill-loader' in h.get('command', ''): + already_loader = True + break + +if not already_loader: + session_start.append({ + 'hooks': [{ + 'type': 'command', + 'command': loader_cmd, + 'timeout': 5 + }] + }) + print(' ✓ Claude Code — added SessionStart hook to ${settings_file}') +else: + print(' ✓ Claude Code — SessionStart hook already configured') + +with open(path, 'w') as f: + json.dump(data, f, indent=2) " 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 CLI preToolUse + sessionStart hooks 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 + + cat > "${hooks_dir}/devcontainer-guard.json" << COPILOTEOF { "version": 1, "hooks": { @@ -218,7 +266,23 @@ configure_copilot_hook() { } } COPILOTEOF - echo " ✓ Copilot CLI — created ${hooks_file}" + echo " ✓ Copilot CLI — created ${hooks_dir}/devcontainer-guard.json" + + cat > "${hooks_dir}/devcontainer-skill-loader.json" << COPILOTEOF +{ + "version": 1, + "hooks": { + "sessionStart": [ + { + "type": "command", + "bash": "${LOADER_PATH}", + "timeoutSec": 5 + } + ] + } +} +COPILOTEOF + echo " ✓ Copilot CLI — created ${hooks_dir}/devcontainer-skill-loader.json" } echo ""