v1.91.212.0 fix(careful): mask git commit's own message before the MEDIUM checks (#1060, port of #3221) (#3389)

* fix(careful): mask git commit -m content to prevent MEDIUM-tier false positives (#1060)

MEDIUM-tier checks matched the full command string including quoted -m text.
A commit like 'git commit -m fix: prevent drop table in migration' triggered
the SQL DROP warning even though no SQL was being executed.

Build CMD_MEDIUM by stripping -m quoted content from git commit commands
before MEDIUM checks. Real destructive commands outside the quoted string
still match, so true positives are unaffected.

Add tests: commit message patterns do not warn; chained real DROP/TRUNCATE still warns.

* fix(careful): replace sed masking with quote-aware awk scanner (#1060)

The sed-based -m content masker had two bypass paths that capy-ai identified:

  1. Single-quoted message containing a double-quote boundary:
       git commit -m 'note -m "' && rm -rf build && echo '"'
     The double-quote pass matched '-m "' inside the single-quoted string,
     swallowing '&& rm -rf build && echo '; the rm vanished from CMD_MEDIUM.

  2. Double-quoted message with a command substitution:
       git commit -m "$(rm -rf build)"
     sed replaced the content including the $(...), so the substitution
     — which executes in the shell — was masked away.

Replace the sed substitution with a quote-aware awk scanner that:
- Walks the command string tracking single-quote, double-quote, and
  backslash state, so it only treats a delimiter as a message boundary
  when the shell would.
- Masks single-quoted messages (always literal) and double-quoted messages
  that contain no $, backtick, or backslash (safe to mask — no expansion).
- Leaves double-quoted messages with $, backtick, or backslash unmasked
  so command substitutions stay visible to the MEDIUM checks.
- Handles -m, -am, --message, and --message= forms.

Four new tests added as requested (alongside the original six):
- single-quoted message with embedded double-quote + chained rm → warns
- commit message with $(rm -rf build) → warns
- git push --force chained after commit → warns
- git commit -am "drop table notes" → silent (message masked)

* fix(careful): read the commit-message masker from a file, so bash 3.2 can parse the hook (#1060)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(careful): scan the command as one string, so a newline still separates commands from the message (#1060)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(careful): mask only git commit's own message; a piped commit or another command's -m stays visible (#1060)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(careful): mask a quoted-delimiter heredoc message; any other substitution or heredoc leaves the command unmasked (#1060)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* fix(careful): a pipe, process substitution or parameter expansion after the message unmasks it; redirections and comments parse like bash (#1060)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

* docs(changelog): careful masks git commit's own message (#1060, port of #3221)

Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>

---------

Co-authored-by: Bhumika-1432006 <guravbhumika808@gmail.com>
Co-authored-by: capy-ai[bot] <230910855+capy-ai[bot]@users.noreply.github.com>
This commit is contained in:
authored and GitHub committed 2026-10-25 15:44:07 +00:00
1 parent cb99df4a2f
commit f00b8bc79b
7 files changed
+381 -13

No files matched your search

+17
View File
@@ -1,5 +1,22 @@
# Changelog
## [1.91.212.0] - 2026-10-25
**/careful no longer asks about words inside a commit message (#1060; ported from #3221 by @Bhumika-1432006).** The careful hook matched its MEDIUM patterns against the whole command, so `git commit -m "fix: prevent drop table in migration"` asked for approval as an SQL DROP. The hook now masks git commit's own literal message before those checks, including the `-m "$(cat <<'EOF' … EOF
)"` form agents use most. Our commits on top of the contributor's scanner fix four problems found in review: the inline awk program made the whole hook a bash syntax error on bash 3.2 (macOS `/bin/bash`), so every Bash call failed the hook there; a newline after the message was dropped, so `-m x⏎truncate …` stopped asking; any `-m '…'` after a commit was masked, including another command's argument; and the heredoc form was never masked.
### Fixed
- **`careful/bin/mask-commit-message.awk`** (new, from the contributor's scanner): reads the command as one string and keeps its newlines. It splits at unquoted `;` `&` `|` `(` `)` and newlines (a redirection's `&` does not split) and masks `-m`, `-am`, `--message` and `--message=` only in a segment whose first word is `git` with a `commit` word after it. Single-quoted messages, double-quoted messages without `$`, backtick or backslash, and a double-quoted `$(cat <<'DELIM' … DELIM )` with a quoted delimiter are masked. Quotes, backslashes and comments are parsed as bash does. The command is left unmasked when it contains any other command, parameter or process substitution, a backtick, `$'…'`, a heredoc, an unterminated quote, or a pipe after a masked message (git prints the message on stdout).
- **`careful/bin/check-careful.sh`**: runs the program with `awk -f` from the hook's own directory, so bash 3.2 parses the hook again. A missing program or an awk error leaves the command unmasked. The HIGH deny tier, the PowerShell table and the obfuscation tripwire still read the unmasked command.
### Tests
- `test/hook-scripts.test.ts` keeps the contributor's 10 cases and adds 31. Masked (no prompt): every flag form, `git -C repo commit`, `--amend`, multi-line messages and four heredoc spellings (`'EOF'`, `"MSG"`, `<<-` with a tab, a quoted word in the body). Still asking: a command on the next line, a quote that spans a line break, another command's `-m`, `echo git commit -m '…'`, an escaped quote, a backslash-newline, a heredoc with an unquoted delimiter, a substitution that closes the message early, a backtick, `git commit -F - <<'EOF'`, `$'…'`, a pipe after the commit (direct, after `2>&1`, or from a subshell), a process substitution and a parameter expansion. A partial install without the awk file still asks.
- The same cases pass with mawk and the one-true-awk (`original-awk`, the awk macOS ships). In a `bash:3.2` container the hook as submitted in #3221 exits 2 with `unexpected EOF while looking for matching '"'` on every command; with this change it answers normally.
### Gate integrity
- One accepted-input change to review, in a safety hook: MEDIUM checks (and custom `careful-patterns.txt` rules) no longer see the masked text of git commit's own literal message. That is a weakening by design, limited to text that bash passes to git as a literal argument. The negative controls listed under Tests still ask. Commands that combine a commit with anything that could execute the message, or that the scanner cannot read with certainty, are checked unmasked, exactly as on main. No threshold, budget, timeout, trial count or case kind changed.
- Known limit, still a false positive as on main: `git commit -F - <<'EOF'` messages and commits after an environment assignment or `sudo` are not masked.
## [1.91.211.0] - 2026-10-25
**`gstack-diff-scope` sees Python tests and nested test directories (#3335).** A branch that changed `components/orch/services/shared/tests/test_native_store_custody.py` reported `SCOPE_TESTS=false`, because the Tests rule only knew `*.test.*`-style suffixes and `test/`, `tests/`, `spec/` and `__tests__/` at the repository root. /ship and /review use that flag to route the testing specialist, so a backend change whose tests follow pytest's `test_*.py` convention skipped it. The test-bootstrap resolver already counted these files, so the two detectors disagreed.
+1 -1
View File
@@ -1 +1 @@
1.91.211.0
1.91.212.0
+1 -1
View File
@@ -1,4 +1,4 @@
# gstack digest v1.91.211.0 — regenerate/re-copy after upgrading gstack
# gstack digest v1.91.212.0 — regenerate/re-copy after upgrading gstack
Behavioral rules from gstack (https://github.com/garrytan/gstack), compressed
for agent hosts without a full skill install. The full skills add workflows,
+27 -10
View File
@@ -224,6 +224,23 @@ esac
WARN=""
PATTERN=""
# Mask quoted -m/-am/--message argument content in git commit commands so that
# descriptive commit messages do not trigger false positives (#1060). The
# quote-aware scanner lives in mask-commit-message.awk beside this hook: as a
# heredoc inside a command substitution, its quote characters are mis-parsed by
# bash 3.2 (macOS /bin/bash), which made this whole hook a syntax error there.
# A missing program or an awk failure leaves the command unmasked.
CMD_MEDIUM="$CMD"
CMD_MEDIUM_LOWER="$CMD_LOWER"
_MASK_AWK="$_HOOK_DIR/mask-commit-message.awk"
case "$CMD" in *git*commit*) _MAYBE_COMMIT=1 ;; *) _MAYBE_COMMIT=0 ;; esac
if [ "$_MAYBE_COMMIT" -eq 1 ] && [ -f "$_MASK_AWK" ]; then
if _cmd_masked=$(printf '%s' "$CMD" | awk -f "$_MASK_AWK" 2>/dev/null); then
CMD_MEDIUM="$_cmd_masked"
CMD_MEDIUM_LOWER=$(printf '%s' "$CMD_MEDIUM" | tr '[:upper:]' '[:lower:]')
fi
fi
# --- 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 "..."`,
@@ -303,50 +320,50 @@ if [ -n "$_WIN_SCAN" ]; then
fi
# rm -rf / rm -r / rm -R / rm --recursive (capital -R is BSD/macOS recursive)
if [ -z "$WARN" ] && 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_MEDIUM" 2>/dev/null; then
WARN="Destructive: recursive delete (rm -r). This permanently removes files."
PATTERN="rm_recursive"
fi
# DROP TABLE / DROP DATABASE
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'drop\s+(table|database)' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL DROP detected. This permanently deletes database objects."
PATTERN="drop_table"
fi
# TRUNCATE
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_LOWER" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE '\btruncate\b' <<< "$CMD_MEDIUM_LOWER" 2>/dev/null; then
WARN="Destructive: SQL TRUNCATE detected. This deletes all rows from a table."
PATTERN="truncate"
fi
# git push --force / git push -f / plus-refspec force (git push origin +ref)
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+push\s' <<< "$CMD_MEDIUM" 2>/dev/null \
&& grep -qE '(-f\b|--force|(^|[[:space:]])\+[^[:space:]])' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git force-push rewrites remote history. Other contributors may lose work."
PATTERN="git_force_push"
fi
# git reset --hard
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+reset\s+--hard' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: git reset --hard discards all uncommitted changes."
PATTERN="git_reset_hard"
fi
# git checkout . / git restore .
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'git\s+(checkout|restore)\s+\.' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: discards all uncommitted changes in the working tree."
PATTERN="git_discard"
fi
# kubectl delete
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'kubectl\s+delete' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: kubectl delete removes Kubernetes resources. May impact production."
PATTERN="kubectl_delete"
fi
# docker rm -f / docker system prune
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD" 2>/dev/null; then
if [ -z "$WARN" ] && grep -qE 'docker\s+(rm\s+-f|system\s+prune)' <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Destructive: Docker force-remove or prune. May delete running containers or cached images."
PATTERN="docker_destructive"
fi
@@ -386,7 +403,7 @@ $_GSTACK_HOME_DIR/projects/$SLUG/careful-patterns.txt"
_PAT_RC=0
printf '' | grep -qE -- "$_PAT" 2>/dev/null || _PAT_RC=$?
[ "$_PAT_RC" -eq 2 ] && continue # invalid ERE — skip the line
if grep -qE -- "$_PAT" <<< "$CMD" 2>/dev/null; then
if grep -qE -- "$_PAT" <<< "$CMD_MEDIUM" 2>/dev/null; then
WARN="Project rule matched: $_PAT"
PATTERN="project_rule"
break
+125
View File
@@ -0,0 +1,125 @@
# mask-commit-message.awk — read by careful/bin/check-careful.sh (#1060).
# Prints the command with the quoted message argument of git commit
# (-m, -am, --message) replaced by an empty quoted string. The command is
# scanned as one string: newlines are kept, since bash runs each line as its
# own command, and quote state carries across lines.
#
# Only git commit's own arguments are masked: the scanner splits the command
# at unquoted ; & | ( ) and newlines, and masks a message flag only in a
# segment whose first word is git and that has a commit word after it. The &
# of a redirection (2>&1, &>) does not split. Any pipe after a masked message
# prints the command unchanged, because git prints the message on stdout.
# Quotes, backslashes and comments follow bash: a backslash escapes the next
# character outside single quotes, and nothing inside single quotes; an
# unquoted # starting a word runs to the end of its line. An unterminated
# quote or escape prints the command unchanged.
#
# A message written as "$(cat <<'EOF' ... EOF )" (a quoted delimiter, so the
# body is never expanded) is masked whole. Any other command, parameter or
# process substitution, backtick, $'...' string or heredoc changes how bash
# reads the quotes after it, so the command is printed unchanged.
# heredoc_msg S N I — S[I] opens a double-quoted "$(cat <<'DELIM' ... DELIM )"
# message: the index after its closing quote, else 0.
function heredoc_msg(s, n, i, p, rest, strip, delim, e, line) {
rest = substr(s, i + 1)
if (!match(rest, /^\$\(cat[ \t]+<<-?[ \t]*/)) return 0
strip = substr(rest, 1, RLENGTH) ~ /<<-/
p = i + 1 + RLENGTH
rest = substr(s, p)
if (match(rest, /^'[A-Za-z_][A-Za-z0-9_]*'/) || match(rest, /^"[A-Za-z_][A-Za-z0-9_]*"/)) delim = substr(rest, 2, RLENGTH - 2)
else if (match(rest, /^\\[A-Za-z_][A-Za-z0-9_]*/)) delim = substr(rest, 2, RLENGTH - 1)
else return 0
p += RLENGTH
while (p <= n && substr(s, p, 1) ~ /[ \t]/) p++
if (substr(s, p, 1) != "\n") return 0
p++
for (;;) {
if (p > n) return 0
e = index(substr(s, p), "\n")
if (!e) return 0
line = substr(s, p, e - 1)
if (strip) sub(/^\t+/, "", line)
p += e
if (line == delim) break
}
while (p <= n && substr(s, p, 1) ~ /[ \t\n]/) p++
if (substr(s, p, 2) != ")\"") return 0
return p + 2
}
function handle_msg(s, n, i, j, c, safe) {
if (i > n) return i
if (substr(s, i, 1) == "'") {
j = i + 1
while (j <= n && substr(s, j, 1) != "'") j++
if (j > n) return i
result = result "''"
return j + 1
}
if (substr(s, i, 1) == "\"") {
if ((j = heredoc_msg(s, n, i)) > 0) { result = result "\"\""; return j }
j = i + 1; safe = 1
while (j <= n && (c = substr(s, j, 1)) != "\"") {
if (c == "$" || c == "`" || c == "\\") safe = 0
j++
}
if (!safe || j > n) return i
result = result "\"\""
return j + 1
}
return i
}
# flag S I — length of the message flag (with its trailing blanks) at S[I], or 0
function flag(s, i, rest, m) {
rest = substr(s, i)
if (rest ~ /^--message=/) return 10
if (match(rest, /^(--message|-am|-m)[ \t]+/)) return RLENGTH
return 0
}
{ cmd = (NR > 1) ? cmd "\n" $0 : $0 }
END {
s = cmd
n = length(s); sq = 0; dq = 0; bs = 0; i = 1
atword = 1; words = 0; git = 0; commit = 0; masked = 0; piped = 0; unsure = 0
while (i <= n) {
c = substr(s, i, 1)
if (bs) { result = result c; bs = 0; i++; continue }
if (sq) { if (c == "'") sq = 0; result = result c; i++; continue }
two = substr(s, i, 2)
if (c == "`" || two == "$(" || two == "${") unsure = 1
if (dq) {
if (c == "\\") bs = 1
else if (c == "\"") dq = 0
result = result c; i++; continue
}
if (two == "<<" || two == "$'" || two == "<(" || two == ">(") unsure = 1
if (atword && c == "#") {
while (i <= n && substr(s, i, 1) != "\n") { result = result substr(s, i, 1); i++ }
continue
}
if (c == "&" && (substr(s, i - 1, 1) ~ /[<>]/ || substr(s, i + 1, 1) == ">")) { atword = 0; result = result c; i++; continue }
if (c ~ /[;&|()\n]/) {
if (c == "|" && masked && substr(s, i + 1, 1) != "|" && substr(s, i - 1, 1) != "|") piped = 1
atword = 1; words = 0; git = 0; commit = 0
result = result c; i++; continue
}
if (c == " " || c == "\t") { atword = 1; result = result c; i++; continue }
if (atword) {
atword = 0; words++
rest = substr(s, i)
if (words == 1 && rest ~ /^git([ \t\n;&|()]|$)/) git = 1
else if (git && !commit && rest ~ /^commit([ \t\n;&|()]|$)/) commit = 1
else if (commit && (len = flag(s, i)) > 0) {
result = result substr(s, i, len); i += len
j = handle_msg(s, n, i)
if (j > i) masked = 1
atword = (j == i); i = j
continue
}
}
if (c == "\\") bs = 1
else if (c == "'") sq = 1
else if (c == "\"") dq = 1
result = result c; i++
}
printf "%s", (sq || dq || bs || piped || unsure) ? s : result
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "gstack",
"version": "1.91.211",
"version": "1.91.212",
"description": "Garry's Stack — Claude Code skills + fast headless browser. One repo, one install, entire AI engineering workflow.",
"license": "MIT",
"type": "module",
+209
View File
@@ -342,6 +342,215 @@ describe('check-careful.sh', () => {
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// --- Commit message false positives (#1060) ---
describe('commit message false positives (#1060)', () => {
// Quoted -m content describes destructive patterns but does not run them.
test.each([
'git commit -m "fix: prevent accidental drop table in migration"',
'git commit -m "docs: explain truncate vs delete semantics"',
'git commit -m "feat: add guard for recursive delete patterns"',
'git add . && git commit -m "fix: handle drop database edge case"',
])('does not warn on descriptive text in commit message: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Real destructive commands chained with a commit still warn.
test('warns when a real DROP TABLE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "release" && psql -c "DROP TABLE staging"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
});
test('warns when a real TRUNCATE follows git commit in a chain', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "cleanup" && psql -c "TRUNCATE audit_log"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL TRUNCATE');
});
// Quote-aware awk scanner: a single-quoted message whose content looks like
// a flag boundary must not swallow real commands that follow via &&.
test('warns when rm follows a single-quoted message that embeds a double-quote', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput(`git commit -m 'note -m "' && rm -rf build && echo '"'`),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A double-quoted message containing $(...) is not masked — the substitution
// content remains visible to the MEDIUM checks.
test('warns when a commit message contains a command substitution with rm', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "$(rm -rf build)"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
// A force-push chained after a commit must still warn even after the message is masked.
test('warns on force-push chained after git commit', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -m "x" && git push --force'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('force-push');
});
// -am shorthand: the message is masked, so "drop table" in the message text
// does not trigger the SQL DROP warning.
test('does not warn on drop-table text in -am commit message', () => {
const { exitCode, output } = runHook(
CAREFUL_SCRIPT,
carefulInput('git commit -am "drop table notes"'),
);
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// bash 3.2 (macOS /bin/bash) mis-parses quote characters in a heredoc
// inside $( ), so the awk program inline made the whole hook a syntax error
// there. It is a file beside the hook, read with awk -f.
test('the masking program is a file beside the hook, not a heredoc inside $( )', () => {
const source = fs.readFileSync(CAREFUL_SCRIPT, 'utf8');
expect(source).not.toMatch(/\$\(cat <</);
expect(source).toContain('awk -f "$_MASK_AWK"');
expect(fs.existsSync(path.join(path.dirname(CAREFUL_SCRIPT), 'mask-commit-message.awk'))).toBe(true);
});
// Newlines separate commands, and quote state carries across them. The
// per-line scanner joined lines (`-m xtruncate`) and restarted quote
// state on every line, so a quoted line break could hide a real command.
test.each([
['git commit -m x\ntruncate -s 0 f', 'SQL TRUNCATE'],
['git commit -m wip\nrm -rf build', 'recursive delete'],
["git commit -m \"a\n-m 'x\" && rm -rf build && echo '", 'recursive delete'],
])('a command on the next line still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test('a multi-line quoted message is masked as one message', () => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput('git commit -m "fix: guard migrations\n\nNo more drop table in the seed script."'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// Only git commit's own message is masked: a -m argument of another
// command in the chain, a commit whose output is piped into a shell, and
// an escaped quote inside the message all stay visible.
test.each([
["git commit -m a && ls | parallel -m 'rm -rf build'", 'recursive delete'],
["git commit -m 'rm -rf build' | sh", 'recursive delete'],
["echo git commit -m 'drop table users'", 'SQL DROP'],
['git commit -m "a\\"b" && rm -rf build', 'recursive delete'],
["git commit -m wip \\\n&& rm -rf build", 'recursive delete'],
])('text outside commit\'s own message still asks: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
test.each([
'git -C repo commit --message="docs: truncate the changelog intro"',
"git commit --message 'chore: drop database fixtures from the seed'",
"git commit --amend -m 'fix: git reset --hard in the docs'",
])('commit\'s own message is masked in every flag form: %s', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
// The form agents use most: a quoted-delimiter heredoc inside "$( )". Its
// body is never expanded, so it is masked whole. Any other substitution,
// backtick, $'...' or heredoc leaves the command unmasked.
const heredocMessage = (body: string, delimiter = "'EOF'", end = 'EOF') =>
`git commit -m "$(cat <<${delimiter}\n${body}\n${end}\n)"`;
test.each([
heredocMessage('fix: guard the migration that could drop table users\n\nCo-Authored-By: A <a@example.com>'),
heredocMessage('docs: why we never truncate "audit" rows (see #12)'),
heredocMessage('chore: git reset --hard is documented', '"MSG"', 'MSG'),
heredocMessage('chore: rm -rf build in the clean script', "-'EOF'", '\tEOF'),
])('a quoted-delimiter heredoc message is masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test.each([
[heredocMessage('docs: drop table notes') + ' && git push --force', 'force-push'],
[heredocMessage('drop table $(rm -rf build)', 'EOF'), 'recursive delete'],
[`git commit -m "$(echo " -m 'x" ; rm -rf build ; echo ')"`, 'recursive delete'],
['git commit -m "\`rm -rf build\`"', 'recursive delete'],
["git commit -F - <<'EOF'\ndocs: drop table notes\nEOF", 'SQL DROP'],
["git commit -m $'drop table notes\\n'", 'SQL DROP'],
])('other substitutions and heredocs stay unmasked: %j', (command, reason) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain(reason);
});
// git prints the message on stdout, so any pipe after a masked message,
// a process substitution or a parameter expansion leaves it unmasked; a
// redirection's & is not a separator, and a comment's quote is not a quote.
test.each([
"git commit -m 'rm -rf build' 2>&1 | sh",
"(git commit -m 'rm -rf build') | sh",
"git commit -m 'rm -rf build' > >(sh)",
'git commit -m "${MSG:-rm -rf build}"',
])('a message that can reach a shell stays unmasked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('recursive delete');
});
test.each([
"git commit -m 'drop table notes' 2>&1 && git log -1 --oneline",
"git commit -m 'drop table notes' || echo failed",
"git commit -m 'drop table notes' # it's the docs commit",
])('redirections, || and comments keep the message masked: %j', (command) => {
const { exitCode, output } = runHook(CAREFUL_SCRIPT, carefulInput(command));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBeUndefined();
});
test('a partial install without the masking program still asks on the message text', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-careful-nomask-'));
try {
for (const file of ['check-careful.sh', 'hook-extract.sh']) {
fs.copyFileSync(path.join(path.dirname(CAREFUL_SCRIPT), file), path.join(dir, file));
}
const { exitCode, output } = runHook(path.join(dir, 'check-careful.sh'), carefulInput('git commit -m "drop table notes"'));
expect(exitCode).toBe(0);
expect(output.hookSpecificOutput?.permissionDecision).toBe('ask');
expect(output.hookSpecificOutput?.permissionDecisionReason).toContain('SQL DROP');
} finally {
fs.rmSync(dir, { recursive: true, force: true });
}
});
});
// --- Shell obfuscation ---
describe('shell obfuscation', () => {