mirror of https://github.com/garrytan/gstack.git
fix(careful): parse hook commands as JSON first
This commit is contained in:
parent
83b1bd5355
commit
630e3d3a28
|
|
@ -7,13 +7,24 @@ set -euo pipefail
|
|||
# Read stdin (JSON with tool_input)
|
||||
INPUT=$(cat)
|
||||
|
||||
# Extract the "command" field value from tool_input
|
||||
# Try grep/sed first (handles 99% of cases), fall back to Python for escaped quotes
|
||||
CMD=$(printf '%s' "$INPUT" | grep -o '"command"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*:[[:space:]]*"//;s/"$//' || true)
|
||||
|
||||
# Python fallback if grep returned empty (e.g., escaped quotes in command)
|
||||
if [ -z "$CMD" ]; then
|
||||
CMD=$(printf '%s' "$INPUT" | python3 -c 'import sys,json; print(json.loads(sys.stdin.read()).get("tool_input",{}).get("command",""))' 2>/dev/null || true)
|
||||
# Parse JSON before inspecting the command. Regex extraction cannot distinguish
|
||||
# escaped quotes from the end of a JSON string and can hide a destructive tail.
|
||||
# A malformed hook payload is itself unsafe, so fail closed and ask.
|
||||
if ! CMD=$(printf '%s' "$INPUT" | python3 -c '
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("hook payload must be an object")
|
||||
tool_input = data.get("tool_input", {})
|
||||
if not isinstance(tool_input, dict):
|
||||
raise ValueError("tool_input must be an object")
|
||||
command = tool_input.get("command", "")
|
||||
if not isinstance(command, str):
|
||||
raise ValueError("command must be a string")
|
||||
sys.stdout.write(command)
|
||||
' 2>/dev/null); then
|
||||
echo '{"permissionDecision":"ask","message":"[careful] Could not parse the command payload safely. Review it before proceeding."}'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# If we still couldn't extract a command, allow
|
||||
|
|
@ -30,7 +41,7 @@ CMD_LOWER=$(printf '%s' "$CMD" | tr '[:upper:]' '[:lower:]')
|
|||
# syntax or comments can hide an earlier destructive command, for example:
|
||||
# rm -rf / # rm -rf node_modules
|
||||
# Unknown syntax fails closed and falls through to the destructive checks.
|
||||
if printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
|
||||
if [[ "$CMD" != *$'\n'* ]] && printf '%s' "$CMD" | grep -qE '^[[:space:]]*rm[[:space:]]+(-[a-zA-Z]*r[a-zA-Z]*[[:space:]]+|--recursive[[:space:]]+)(([^[:space:];&|#]*/)?(node_modules|\.next|dist|__pycache__|\.cache|build|\.turbo|coverage)[[:space:]]*)+$' 2>/dev/null; then
|
||||
echo '{}'
|
||||
exit 0
|
||||
fi
|
||||
|
|
|
|||
|
|
@ -114,11 +114,6 @@ describe('check-careful.sh', () => {
|
|||
});
|
||||
|
||||
// --- SQL destructive commands ---
|
||||
// Note: SQL commands that contain embedded double quotes (e.g., psql -c "DROP TABLE")
|
||||
// get their command value truncated by the grep-based JSON extractor because \"
|
||||
// terminates the [^"]* match. We use commands WITHOUT embedded quotes so the grep
|
||||
// extraction works and the SQL keywords are visible to the pattern matcher.
|
||||
|
||||
describe('SQL destructive commands', () => {
|
||||
test('psql DROP TABLE warns with DROP in message', () => {
|
||||
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('psql -c DROP TABLE users;'));
|
||||
|
|
@ -140,6 +135,16 @@ describe('check-careful.sh', () => {
|
|||
expect(output.permissionDecision).toBe('ask');
|
||||
expect(output.message).toContain('TRUNCATE');
|
||||
});
|
||||
|
||||
test('escaped quotes cannot hide DROP TABLE from JSON parsing', () => {
|
||||
const { exitCode, output } = runHook(
|
||||
CAREFUL_SCRIPT,
|
||||
carefulInput('psql -c "DROP TABLE users"'),
|
||||
);
|
||||
expect(exitCode).toBe(0);
|
||||
expect(output.permissionDecision).toBe('ask');
|
||||
expect(output.message).toContain('DROP');
|
||||
});
|
||||
});
|
||||
|
||||
// --- Git destructive commands ---
|
||||
|
|
@ -241,17 +246,14 @@ describe('check-careful.sh', () => {
|
|||
expect(output.permissionDecision).toBeUndefined();
|
||||
});
|
||||
|
||||
test('malformed JSON input allows gracefully (exit 0, output {})', () => {
|
||||
const { exitCode, raw } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{');
|
||||
test('malformed JSON input fails closed', () => {
|
||||
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, 'this is not json at all{{{{');
|
||||
expect(exitCode).toBe(0);
|
||||
expect(raw).toBe('{}');
|
||||
expect(output.permissionDecision).toBe('ask');
|
||||
expect(output.message).toContain('parse');
|
||||
});
|
||||
|
||||
test('Python fallback: grep fails on multiline JSON, Python parses it', () => {
|
||||
// Construct JSON where "command": and the value are on separate lines.
|
||||
// grep works line-by-line, so it cannot match "command"..."value" across lines.
|
||||
// This forces CMD to be empty, triggering the Python fallback which handles
|
||||
// the full JSON correctly.
|
||||
test('JSON parser handles a command value on the next line', () => {
|
||||
const rawJson = '{"tool_input":{"command":\n"rm -rf /tmp/important"}}';
|
||||
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, rawJson);
|
||||
expect(exitCode).toBe(0);
|
||||
|
|
|
|||
Loading…
Reference in New Issue