diff --git a/.github/hooks/devcontainer-guard.sh b/.github/hooks/devcontainer-guard.sh index 430e308..bd400af 100755 --- a/.github/hooks/devcontainer-guard.sh +++ b/.github/hooks/devcontainer-guard.sh @@ -17,51 +17,22 @@ # 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: 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') +# Fail open by design. The ONLY way this hook blocks a command is by printing a +# "deny" decision to stdout — blocking is NEVER signaled through the exit code. +# All decision logic runs in a subshell (`decide`) whose stdout we capture: if +# anything unexpected fails inside it (jq missing, an unparseable payload, a +# failing helper), the subshell exits non-zero and we fall through to ALLOW. +# This matters because an agent treats a non-zero hook exit as "deny", which +# would block every command on the host — even in repos with no devcontainer. +# +# NOTE: we deliberately do NOT rely on an ERR trap + `exit`, because macOS ships +# bash 3.2, where `exit` inside an ERR trap fired by a `var=$(cmd)` assignment +# does not terminate the script. Capturing the subshell's exit status is the +# portable way to fail open. -# 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 -) +# Agent hosts often spawn hooks with a minimal PATH that omits Homebrew +# (/opt/homebrew/bin) and /usr/local/bin, where jq is commonly installed. +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" # Extract all meaningful commands from a shell string, skipping env vars # (KEY=VALUE) and cd/pushd/popd. Splits on &&, ||, ;, and | to catch every @@ -84,46 +55,98 @@ all_commands() { 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 +# Prints a "deny" JSON decision to stdout ONLY when the tool call must be +# blocked; prints nothing for every allow case. Runs with `set -e` so that a +# missing dependency (jq) or malformed input aborts with a non-zero status, +# which the caller turns into a fail-open ALLOW. +decide() { + set -euo pipefail + local input="$1" + local tool_name cwd tool_input cmd_string reason + local all_allowed had_cmd cmd_name allowed found + + # Try Claude Code fields first (snake_case), fall back to Copilot CLI (camelCase) + tool_name=$(printf '%s' "$input" | jq -r '.tool_name // .toolName // empty') + cwd=$(printf '%s' "$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) ;; + *) return 0 ;; + esac + + tool_input=$(printf '%s' "$input" | jq -r '(.tool_input // .toolArgs // {}) | tostring') + + # Bypass: allow when the operator explicitly confirmed a host operation + if printf '%s' "$tool_input" | grep -q 'USER_CONFIRMED_HOST_OPERATION=1'; then + return 0 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 --- + # No cwd in payload — can't determine context, allow through + [ -n "$cwd" ] || return 0 + + # No devcontainer in the working directory — allow through + [ -f "${cwd}/.devcontainer/devcontainer.json" ] || return 0 + + # --- Devcontainer exists: allow only if every command is host-safe --- + cmd_string=$(printf '%s' "$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. + local ALLOWED_HOST_COMMANDS=(git gh) + + all_allowed=true + had_cmd=false + while IFS= read -r cmd_name; do + [ -z "$cmd_name" ] && continue + had_cmd=true + 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 "$cmd_string") -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." + # Every command in the chain was on the allowlist — allow through + if [ "$all_allowed" = true ] && [ "$had_cmd" = true ]; then + return 0 + fi -# 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", + # --- Block: emit a deny decision in the right agent format --- + 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." + + if [ -n "$(printf '%s' "$input" | jq -r '.tool_name // empty')" ]; then + # Claude Code format + jq -n --arg reason "$reason" '{ + hookSpecificOutput: { + hookEventName: "PreToolUse", + permissionDecision: "deny", + permissionDecisionReason: $reason + } + }' + else + # Copilot CLI format + jq -n --arg reason "$reason" '{ permissionDecision: "deny", permissionDecisionReason: $reason - } - }' + }' + fi +} + +INPUT=$(cat) + +if DECISION=$(decide "$INPUT"); then + # decide completed cleanly: DECISION is a deny JSON (block) or empty (allow) + [ -n "$DECISION" ] && printf '%s\n' "$DECISION" else - # Copilot CLI format - jq -n --arg reason "$DENY_REASON" '{ - permissionDecision: "deny", - permissionDecisionReason: $reason - }' + # decide crashed (jq missing, unparseable payload, ...): FAIL OPEN (allow) + echo "devcontainer-guard: hook error; allowing tool call (install jq to enable host protection)." >&2 fi + +exit 0 diff --git a/.github/hooks/devcontainer-skill-loader.sh b/.github/hooks/devcontainer-skill-loader.sh index 5dcfbc9..187f0a3 100755 --- a/.github/hooks/devcontainer-skill-loader.sh +++ b/.github/hooks/devcontainer-skill-loader.sh @@ -9,41 +9,61 @@ # Claude Code: { tool_name, tool_input, cwd, ... } # Copilot CLI: { toolName, toolArgs, cwd, ... } -set -euo pipefail +# Fail open by design: this hook only *adds* context and must never disrupt +# session startup. All logic runs in a subshell (`build_context`) whose stdout +# we capture; if anything unexpected fails inside it (jq missing, unparseable +# payload), the subshell exits non-zero and we simply start the session without +# injected context. +# +# NOTE: we deliberately do NOT rely on an ERR trap + `exit`, because macOS ships +# bash 3.2, where `exit` inside an ERR trap fired by a `var=$(cmd)` assignment +# does not terminate the script. Capturing the subshell's exit status is the +# portable way to fail open. -INPUT=$(cat) +# Agent hosts often spawn hooks with a minimal PATH that omits Homebrew +# (/opt/homebrew/bin) and /usr/local/bin, where jq is commonly installed. +export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:${PATH:-}" -# Extract working directory from the payload -CWD=$(echo "$INPUT" | jq -r '.cwd // empty') +# Prints the additionalContext JSON to stdout when a devcontainer + SKILL.md are +# found; prints nothing otherwise. Runs with `set -e` so a missing dependency +# (jq) or malformed input aborts non-zero and the caller injects no context. +build_context() { + set -euo pipefail + local input="$1" + local cwd skill_path skill_content p + local SEARCH_PATHS -if [ -z "$CWD" ]; then - exit 0 -fi + cwd=$(printf '%s' "$input" | jq -r '.cwd // empty') -if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then - exit 0 -fi + [ -n "$cwd" ] || return 0 + [ -f "${cwd}/.devcontainer/devcontainer.json" ] || return 0 -# 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 + # Look for SKILL.md in order of preference + 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" + ) + + skill_path="" + for p in "${SEARCH_PATHS[@]}"; do + if [ -f "$p" ]; then + skill_path="$p" + break + fi + done -SKILL_CONTENT=$(cat "$SKILL_PATH") + [ -n "$skill_path" ] || return 0 + + skill_content=$(cat "$skill_path") + jq -n --arg ctx "$skill_content" '{ "additionalContext": $ctx }' +} + +INPUT=$(cat) + +if CONTEXT=$(build_context "$INPUT"); then + [ -n "$CONTEXT" ] && printf '%s\n' "$CONTEXT" +fi -jq -n --arg ctx "$SKILL_CONTENT" '{ "additionalContext": $ctx }' +exit 0 diff --git a/install.ps1 b/install.ps1 index 86cbdda..656ecc9 100644 --- a/install.ps1 +++ b/install.ps1 @@ -154,6 +154,16 @@ if ($loaderResult -match "OK") { $WslSkillDataPath = "~/.local/share/devcontainer-mcp/SKILL.md" wsl -d $WslDistro bash -c "curl -fsSL -o '$WslSkillDataPath' '$skillUrl'" 2>&1 | Out-Null +# The host-protection hooks parse their JSON payload with jq inside WSL. +# Without jq the hooks fail open (commands pass through), so protection is +# effectively disabled until jq is installed in the WSL distro. +$jqCheck = wsl -d $WslDistro bash -lc "command -v jq" 2>&1 +if ($LASTEXITCODE -eq 0) { + Write-Ok "jq (host-protection hooks active)" +} else { + Write-Fail "jq — hooks INACTIVE until installed — https://jqlang.github.io/jq/download/" +} + # Configure Claude Code PreToolUse + SessionStart hooks (Windows-side) Write-Step "Configuring agent hooks..." diff --git a/install.sh b/install.sh index 4407da6..9cacab3 100755 --- a/install.sh +++ b/install.sh @@ -144,6 +144,13 @@ curl -fsSL -o "$LOADER_PATH" "$LOADER_URL" 2>/dev/null && \ chmod +x "$LOADER_PATH" && \ echo " ${LOADER_PATH}" || echo " ⚠ Could not download skill-loader hook" +# The host-protection hooks parse their JSON payload with jq. Without jq the +# hooks fail open (they allow commands through) so they can never block your +# shell — but host protection is effectively disabled until jq is installed. +command -v jq >/dev/null 2>&1 \ + && echo " ✓ jq (host-protection hooks active)" \ + || echo " ✗ jq — hooks INACTIVE until installed — https://jqlang.github.io/jq/download/" + # 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 && \