Skip to content

Commit 083f901

Browse files
Merge pull request #60 from instil/fix/pre-tool-use-hook-jq-dependency
fix: rewrite pre-tool-use hook for cross-platform compatibility
2 parents 2bfd788 + 4344d9e commit 083f901

1 file changed

Lines changed: 90 additions & 21 deletions

File tree

.github/hooks/pre-tool-use.sh

Lines changed: 90 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -6,35 +6,104 @@
66
# would prompt the user for permission on every state update — interrupting the
77
# tutorial flow.
88
#
9-
# Input format (per https://docs.github.com/en/copilot/tutorials/copilot-cli-hooks):
10-
# { "toolName": "edit", "toolArgs": "{\"path\":\"...\"}" }
11-
# toolArgs is a JSON *string* that must be parsed separately.
9+
# Strategy: try node first (a guaranteed tutorial dependency, already used by
10+
# prompt-context.sh), then fall back to pure POSIX sed/tr for environments
11+
# where node is not on PATH.
12+
#
13+
# Works on: macOS, Linux, Git Bash (Windows), WSL.
14+
#
15+
# Input format (both variants are handled):
16+
# toolArgs as JSON string: { "toolName": "edit", "toolArgs": "{\"path\":\"...\"}" }
17+
# toolArgs as inline object: { "toolName": "edit", "toolArgs": { "path": "..." } }
1218

13-
# Read hook input from stdin
19+
# Read all of stdin
1420
INPUT=$(cat)
1521

16-
# Safely parse the structured hook payload. Default-deny if jq is unavailable.
17-
if ! command -v jq > /dev/null 2>&1; then
22+
# ---------------------------------------------------------------------------
23+
# Primary path: node (reliable JSON parsing, no regex fragility).
24+
# Input is passed via HOOK_INPUT env var to avoid heredoc stdin conflicts.
25+
# ---------------------------------------------------------------------------
26+
if command -v node > /dev/null 2>&1; then
27+
RESULT=$(HOOK_INPUT="$INPUT" node -e "
28+
var path = require('path');
29+
var raw = process.env.HOOK_INPUT || '';
30+
var data;
31+
try { data = JSON.parse(raw); } catch(e) { process.exit(0); }
32+
33+
var toolName = data.toolName || '';
34+
if (toolName !== 'edit' && toolName !== 'create') process.exit(0);
35+
36+
// toolArgs may be a JSON string or an inline object
37+
var toolArgs = data.toolArgs || {};
38+
if (typeof toolArgs === 'string') {
39+
try { toolArgs = JSON.parse(toolArgs); } catch(e) { process.exit(0); }
40+
}
41+
42+
var rawPath = (toolArgs.path || '').replace(/\\\\/g, '/');
43+
44+
// Normalise to a path relative to CWD (handles both absolute and relative
45+
// paths, equivalent to Python's os.path.relpath).
46+
var filePath = path.relative(process.cwd(), path.resolve(rawPath));
47+
48+
// Reject directory traversal
49+
if (filePath.split(path.sep).indexOf('..') !== -1) process.exit(0);
50+
51+
// Normalise separator to forward slash (Windows path.sep is backslash)
52+
filePath = filePath.replace(/\\\\/g, '/');
53+
54+
if (filePath === 'tutorial/state.json') {
55+
process.stdout.write('{\"permissionDecision\": \"allow\"}\n');
56+
}
57+
process.exit(0);
58+
")
59+
if [ -n "$RESULT" ]; then
60+
printf '%s\n' "$RESULT"
61+
fi
1862
exit 0
1963
fi
2064

21-
TOOL_NAME="$(echo "$INPUT" | jq -r '.toolName // empty')"
22-
TOOL_ARGS_RAW="$(echo "$INPUT" | jq -r '.toolArgs // empty')"
23-
FILE_PATH="$(echo "$TOOL_ARGS_RAW" | jq -r '.path // empty' 2>/dev/null)"
24-
25-
# Normalize the path to prevent directory traversal bypasses.
26-
# Uses python3 for portability (realpath -m/--relative-to is GNU-only, not available on macOS).
27-
NORMALIZED_PATH=""
28-
if [ -n "$FILE_PATH" ]; then
29-
if command -v python3 > /dev/null 2>&1; then
30-
NORMALIZED_PATH="$(python3 -c "import os; print(os.path.relpath('$FILE_PATH'))" 2>/dev/null)"
31-
else
32-
NORMALIZED_PATH="${FILE_PATH#./}"
33-
fi
65+
# ---------------------------------------------------------------------------
66+
# Fallback: pure POSIX utilities (sed, tr) — no external dependencies
67+
# ---------------------------------------------------------------------------
68+
69+
# Collapse to a single line and strip Windows \r
70+
SINGLE=$(printf '%s' "$INPUT" | tr -d '\n\r')
71+
72+
# Extract toolName (top-level unescaped quotes — safe from toolArgs content)
73+
TOOL_NAME=$(printf '%s\n' "$SINGLE" | sed -n 's/.*"toolName"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
74+
case "$TOOL_NAME" in
75+
edit|create) ;;
76+
*) exit 0 ;;
77+
esac
78+
79+
# Extract path — try escaped-string form first, then inline-object form
80+
FILE_PATH=$(printf '%s\n' "$SINGLE" | sed -n 's/.*\\"path\\"[[:space:]]*:[[:space:]]*\\"\([^"\\]*\)\\".*/\1/p')
81+
if [ -z "$FILE_PATH" ]; then
82+
FILE_PATH=$(printf '%s\n' "$SINGLE" | sed -n 's/.*"path"[[:space:]]*:[[:space:]]*"\([^"]*\)".*/\1/p')
3483
fi
84+
[ -z "$FILE_PATH" ] && exit 0
85+
86+
# Normalize: backslashes → forward slashes, strip leading ./
87+
FILE_PATH=$(printf '%s' "$FILE_PATH" | tr '\\' '/')
88+
FILE_PATH="${FILE_PATH#./}"
89+
90+
# Strip absolute CWD prefix so absolute paths become relative
91+
# e.g. /home/user/project/tutorial/state.json → tutorial/state.json
92+
# Use substring comparison (not glob pattern) to avoid misbehaving when
93+
# CWD contains glob metacharacters (*, ?, [).
94+
CWD=$(pwd | tr '\\' '/')
95+
PREFIX="$CWD/"
96+
if [ "${FILE_PATH:0:${#PREFIX}}" = "$PREFIX" ]; then
97+
FILE_PATH="${FILE_PATH:${#PREFIX}}"
98+
fi
99+
100+
# Reject directory traversal
101+
case "/$FILE_PATH/" in
102+
*/../*) exit 0 ;;
103+
esac
35104

36-
if [ "$TOOL_NAME" = "edit" ] && [ "$NORMALIZED_PATH" = "tutorial/state.json" ]; then
37-
echo '{"permissionDecision": "allow"}'
105+
if [ "$FILE_PATH" = "tutorial/state.json" ]; then
106+
printf '{"permissionDecision": "allow"}\n'
38107
fi
39108

40109
exit 0

0 commit comments

Comments
 (0)