Skip to content

Commit 77ef453

Browse files
authored
Merge pull request #21 from aniongithub/fix/hooks-allowlist
Add hook allowlist for host-safe commands and auto-inject SKILL.md on session start
2 parents 42cda32 + 09eba3f commit 77ef453

7 files changed

Lines changed: 276 additions & 36 deletions

File tree

.github/hooks/devcontainer-guard.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"preToolUse": [
55
{
66
"type": "command",
7-
"bash": "./hooks/devcontainer-guard.sh",
7+
"bash": "./.github/hooks/devcontainer-guard.sh",
88
"timeoutSec": 5
99
}
1010
]
Lines changed: 58 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
# Read-only tools (view, grep, glob) and file edits are allowed through — only
99
# command execution is blocked.
1010
#
11+
# Host-safe commands (git, gh, curl, etc.) are allowlisted and always permitted
12+
# since they operate on the repo/host, not the project's build environment.
13+
#
1114
# Bypass: include USER_CONFIRMED_HOST_OPERATION=1 in the command.
1215
#
1316
# Supports both agent payload formats:
@@ -48,7 +51,61 @@ if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
4851
exit 0
4952
fi
5053

51-
# --- Devcontainer exists: block the tool call ---
54+
# --- Devcontainer exists: check allowlist before blocking ---
55+
56+
# Extract the command string from tool input (handles both formats)
57+
COMMAND=$(echo "$INPUT" | jq -r '(.tool_input.command // .toolArgs.command // "") | tostring')
58+
59+
# Commands that are safe to run on the host even when a devcontainer exists.
60+
# These operate on the repo/host itself, not on the project's build environment.
61+
ALLOWED_HOST_COMMANDS=(
62+
git
63+
gh
64+
)
65+
66+
# Extract all meaningful commands from a shell string, skipping env vars
67+
# (KEY=VALUE) and cd/pushd/popd. Splits on &&, ||, ;, and | to catch every
68+
# command in a chain or pipeline.
69+
all_commands() {
70+
local cmd="$1"
71+
while IFS= read -r segment; do
72+
segment="${segment#"${segment%%[![:space:]]*}"}"
73+
[ -z "$segment" ] && continue
74+
for token in $segment; do
75+
if [[ "$token" == *=* && "$token" != -* ]]; then
76+
continue
77+
fi
78+
case "$token" in
79+
cd|pushd|popd) break ;;
80+
esac
81+
basename "$token"
82+
break
83+
done
84+
done < <(echo "$cmd" | sed 's/ *&& */\n/g; s/ *|| */\n/g; s/ *; */\n/g; s/ *| */\n/g')
85+
}
86+
87+
# Every command in the chain must be on the allowlist
88+
ALL_ALLOWED=true
89+
while IFS= read -r cmd_name; do
90+
[ -z "$cmd_name" ] && continue
91+
FOUND=false
92+
for allowed in "${ALLOWED_HOST_COMMANDS[@]}"; do
93+
if [ "$cmd_name" = "$allowed" ]; then
94+
FOUND=true
95+
break
96+
fi
97+
done
98+
if [ "$FOUND" = false ]; then
99+
ALL_ALLOWED=false
100+
break
101+
fi
102+
done < <(all_commands "$COMMAND")
103+
104+
if [ "$ALL_ALLOWED" = true ] && [ -n "$(all_commands "$COMMAND")" ]; then
105+
exit 0
106+
fi
107+
108+
# --- Not on the allowlist: block the tool call ---
52109

53110
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."
54111

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"version": 1,
3+
"hooks": {
4+
"sessionStart": [
5+
{
6+
"type": "command",
7+
"bash": "./.github/hooks/devcontainer-skill-loader.sh",
8+
"timeoutSec": 5
9+
}
10+
]
11+
}
12+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#!/usr/bin/env bash
2+
# devcontainer-skill-loader.sh — SessionStart hook for Claude Code & Copilot CLI
3+
#
4+
# When a session starts in a directory with .devcontainer/devcontainer.json,
5+
# injects the devcontainer-mcp SKILL.md content as additionalContext so the
6+
# agent automatically knows how to use devcontainer-mcp tools.
7+
#
8+
# Supports both agent payload formats:
9+
# Claude Code: { tool_name, tool_input, cwd, ... }
10+
# Copilot CLI: { toolName, toolArgs, cwd, ... }
11+
12+
set -euo pipefail
13+
14+
INPUT=$(cat)
15+
16+
# Extract working directory from the payload
17+
CWD=$(echo "$INPUT" | jq -r '.cwd // empty')
18+
19+
if [ -z "$CWD" ]; then
20+
exit 0
21+
fi
22+
23+
if [ ! -f "${CWD}/.devcontainer/devcontainer.json" ]; then
24+
exit 0
25+
fi
26+
27+
# Look for SKILL.md in order of preference
28+
SKILL_PATH=""
29+
SEARCH_PATHS=(
30+
"${HOME}/.local/share/devcontainer-mcp/SKILL.md"
31+
"${HOME}/.copilot/skills/devcontainer-mcp/SKILL.md"
32+
"${HOME}/.claude/skills/devcontainer-mcp/SKILL.md"
33+
"${HOME}/.agents/skills/devcontainer-mcp/SKILL.md"
34+
)
35+
36+
for p in "${SEARCH_PATHS[@]}"; do
37+
if [ -f "$p" ]; then
38+
SKILL_PATH="$p"
39+
break
40+
fi
41+
done
42+
43+
if [ -z "$SKILL_PATH" ]; then
44+
exit 0
45+
fi
46+
47+
SKILL_CONTENT=$(cat "$SKILL_PATH")
48+
49+
jq -n --arg ctx "$SKILL_CONTENT" '{ "additionalContext": $ctx }'

crates/devcontainer-mcp-core/tests/hook_guard_test.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ fn repo_root() -> std::path::PathBuf {
1515
}
1616

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

2121
/// Run the hook script with the given JSON input and return (stdout, exit_code).

install.ps1

Lines changed: 76 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -129,23 +129,36 @@ foreach ($dir in $skillDirs) {
129129

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

132-
$hookUrl = "https://raw.githubusercontent.com/$Repo/main/hooks/devcontainer-guard.sh"
132+
$hookUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-guard.sh"
133+
$loaderUrl = "https://raw.githubusercontent.com/$Repo/main/.github/hooks/devcontainer-skill-loader.sh"
133134
$WslHookDir = "~/.local/share/devcontainer-mcp/hooks"
134135
$WslHookPath = "$WslHookDir/devcontainer-guard.sh"
136+
$WslLoaderPath = "$WslHookDir/devcontainer-skill-loader.sh"
135137

136138
$hookResult = wsl -d $WslDistro bash -c "mkdir -p '$WslHookDir' && curl -fsSL -o '$WslHookPath' '$hookUrl' && chmod +x '$WslHookPath' && echo OK" 2>&1
137139
if ($hookResult -match "OK") {
138-
Write-Ok "Hook script installed in WSL at $WslHookPath"
140+
Write-Ok "Guard hook installed in WSL at $WslHookPath"
139141
} else {
140-
Write-Warn "Could not install hook script in WSL"
142+
Write-Warn "Could not install guard hook in WSL"
141143
}
142144

143-
# Configure Claude Code PreToolUse hook (Windows-side)
144-
Write-Step "Configuring agent host-protection hooks..."
145+
$loaderResult = wsl -d $WslDistro bash -c "curl -fsSL -o '$WslLoaderPath' '$loaderUrl' && chmod +x '$WslLoaderPath' && echo OK" 2>&1
146+
if ($loaderResult -match "OK") {
147+
Write-Ok "Skill-loader hook installed in WSL at $WslLoaderPath"
148+
} else {
149+
Write-Warn "Could not install skill-loader hook in WSL"
150+
}
151+
152+
# Install SKILL.md alongside hooks for the loader to find
153+
$WslSkillDataPath = "~/.local/share/devcontainer-mcp/SKILL.md"
154+
wsl -d $WslDistro bash -c "curl -fsSL -o '$WslSkillDataPath' '$skillUrl'" 2>&1 | Out-Null
155+
156+
# Configure Claude Code PreToolUse + SessionStart hooks (Windows-side)
157+
Write-Step "Configuring agent hooks..."
145158

146159
$claudeSettings = "$env:USERPROFILE\.claude\settings.json"
147160
try {
148-
$hookEntry = @{
161+
$guardEntry = @{
149162
matcher = "Bash"
150163
hooks = @(
151164
@{
@@ -155,35 +168,64 @@ try {
155168
}
156169
)
157170
}
171+
$loaderEntry = @{
172+
hooks = @(
173+
@{
174+
type = "command"
175+
command = "wsl $WslLoaderPath"
176+
timeout = 5
177+
}
178+
)
179+
}
158180

159181
if (Test-Path $claudeSettings) {
160182
$content = Get-Content -Raw $claudeSettings | ConvertFrom-Json
161183
if (-not $content.hooks) {
162184
$content | Add-Member -NotePropertyName "hooks" -NotePropertyValue ([PSCustomObject]@{})
163185
}
186+
187+
# PreToolUse: devcontainer-guard
164188
if (-not $content.hooks.PreToolUse) {
165189
$content.hooks | Add-Member -NotePropertyName "PreToolUse" -NotePropertyValue @()
166190
}
167-
# Check if already configured
168-
$already = $false
191+
$alreadyGuard = $false
169192
foreach ($group in $content.hooks.PreToolUse) {
170193
foreach ($h in $group.hooks) {
171-
if ($h.command -match "devcontainer-guard") { $already = $true; break }
194+
if ($h.command -match "devcontainer-guard") { $alreadyGuard = $true; break }
172195
}
173196
}
174-
if (-not $already) {
175-
$content.hooks.PreToolUse += [PSCustomObject]$hookEntry
176-
$content | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8
177-
Write-Ok "Claude Code — added hook to $claudeSettings"
197+
if (-not $alreadyGuard) {
198+
$content.hooks.PreToolUse += [PSCustomObject]$guardEntry
199+
Write-Ok "Claude Code — added PreToolUse hook"
178200
} else {
179-
Write-Ok "Claude Code — hook already configured"
201+
Write-Ok "Claude Code — PreToolUse hook already configured"
180202
}
203+
204+
# SessionStart: skill-loader
205+
if (-not $content.hooks.SessionStart) {
206+
$content.hooks | Add-Member -NotePropertyName "SessionStart" -NotePropertyValue @()
207+
}
208+
$alreadyLoader = $false
209+
foreach ($group in $content.hooks.SessionStart) {
210+
foreach ($h in $group.hooks) {
211+
if ($h.command -match "skill-loader") { $alreadyLoader = $true; break }
212+
}
213+
}
214+
if (-not $alreadyLoader) {
215+
$content.hooks.SessionStart += [PSCustomObject]$loaderEntry
216+
Write-Ok "Claude Code — added SessionStart hook"
217+
} else {
218+
Write-Ok "Claude Code — SessionStart hook already configured"
219+
}
220+
221+
$content | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8
181222
} else {
182223
$dir = Split-Path $claudeSettings -Parent
183224
if ($dir) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
184225
$config = [PSCustomObject]@{
185226
hooks = [PSCustomObject]@{
186-
PreToolUse = @([PSCustomObject]$hookEntry)
227+
PreToolUse = @([PSCustomObject]$guardEntry)
228+
SessionStart = @([PSCustomObject]$loaderEntry)
187229
}
188230
}
189231
$config | ConvertTo-Json -Depth 10 | Set-Content $claudeSettings -Encoding UTF8
@@ -193,11 +235,12 @@ try {
193235
Write-Warn "Claude Code — could not configure hooks"
194236
}
195237

196-
# Configure Copilot CLI preToolUse hook (Windows-side)
238+
# Configure Copilot CLI preToolUse + sessionStart hooks (Windows-side)
197239
$copilotHooksDir = "$env:USERPROFILE\.copilot\hooks"
198240
try {
199241
New-Item -ItemType Directory -Path $copilotHooksDir -Force | Out-Null
200-
$copilotHook = [PSCustomObject]@{
242+
243+
$copilotGuard = [PSCustomObject]@{
201244
version = 1
202245
hooks = [PSCustomObject]@{
203246
preToolUse = @(
@@ -209,8 +252,23 @@ try {
209252
)
210253
}
211254
}
212-
$copilotHook | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8
255+
$copilotGuard | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-guard.json" -Encoding UTF8
213256
Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-guard.json"
257+
258+
$copilotLoader = [PSCustomObject]@{
259+
version = 1
260+
hooks = [PSCustomObject]@{
261+
sessionStart = @(
262+
[PSCustomObject]@{
263+
type = "command"
264+
bash = "wsl $WslLoaderPath"
265+
timeoutSec = 5
266+
}
267+
)
268+
}
269+
}
270+
$copilotLoader | ConvertTo-Json -Depth 10 | Set-Content "$copilotHooksDir\devcontainer-skill-loader.json" -Encoding UTF8
271+
Write-Ok "Copilot CLI — created $copilotHooksDir\devcontainer-skill-loader.json"
214272
} catch {
215273
Write-Warn "Copilot CLI — could not configure hooks"
216274
}

0 commit comments

Comments
 (0)