|
6 | 6 | # would prompt the user for permission on every state update — interrupting the |
7 | 7 | # tutorial flow. |
8 | 8 | # |
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": "..." } } |
12 | 18 |
|
13 | | -# Read hook input from stdin |
| 19 | +# Read all of stdin |
14 | 20 | INPUT=$(cat) |
15 | 21 |
|
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 |
18 | 62 | exit 0 |
19 | 63 | fi |
20 | 64 |
|
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') |
34 | 83 | 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 |
35 | 104 |
|
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' |
38 | 107 | fi |
39 | 108 |
|
40 | 109 | exit 0 |
0 commit comments