Skip to content

Commit 4d3ae00

Browse files
dhruv-15-03Copilot
andcommitted
fix(git-extension): avoid shell interpolation of generated commit messages
- Remove r -d '[:space:]' from commit_style parsing in auto-commit.sh: it stripped ALL whitespace (not just leading/trailing), so commit_style: con ventional was silently normalized to conventional instead of being rejected as unknown (PowerShell version already rejected it correctly). - Add a file-based message-passing channel to both auto-commit scripts: --message-file <path> (bash) / -MessageFile <path> (PowerShell). Agent-generated commit messages may contain quotes, $(...), or backticks; passing them as a shell argument risked command injection if ever inlined into a shell command string. The new flag reads the message from a file instead, so untrusted content never touches a shell command line. The raw positional-argument form is kept for backward compatibility. - Update speckit.git.commit.md to instruct the agent to write the generated message to a temp file (via its file-editing tool) and pass the file path, explicitly warning against inlining the message into a shell command string. - Add test coverage: explicit commit_style: fixed (previously only the absent-key default was tested), --message-file/-MessageFile success path (including injection-shaped content), and missing-file error path, for both bash and PowerShell suites. Assisted-by: GitHub Copilot (model: claude-sonnet-5, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent 70024d4 commit 4d3ae00

4 files changed

Lines changed: 198 additions & 14 deletions

File tree

extensions/git/commands/speckit.git.commit.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,18 @@ This command is invoked as a hook after (or before) core commands. It:
2222
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
2323

2424
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
25-
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Pass this message as the script's second argument. The configured `message` values are ignored in this mode.
25+
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
2626

2727
## Execution
2828

2929
Determine the event name from the hook that triggered this command, then run the script:
3030

31-
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> ["<generated_message>"]`
32-
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> ["<generated_message>"]`
31+
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
32+
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
3333

34-
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass `<generated_message>` when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
34+
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
3535

36-
- If `conventional`: inspect the diff, generate a Conventional Commit message, and pass it as the second argument.
36+
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
3737
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
3838

3939
## Configuration

extensions/git/scripts/bash/auto-commit.sh

Lines changed: 35 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,20 +4,49 @@
44
# Checks per-command config keys in git-config.yml before committing.
55
#
66
# Usage: auto-commit.sh <event_name> [generated_message]
7+
# auto-commit.sh <event_name> --message-file <path>
78
# e.g.: auto-commit.sh after_specify
8-
# e.g.: auto-commit.sh after_specify "feat: add OAuth specification" (commit_style: conventional)
9+
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
10+
#
11+
# --message-file is the preferred way to supply an agent-generated commit
12+
# message: it reads the message from a file instead of a shell argument,
13+
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
14+
# is never interpolated into a shell command line.
915

1016
set -e
1117

1218
EVENT_NAME="${1:-}"
1319
if [ -z "$EVENT_NAME" ]; then
14-
echo "Usage: $0 <event_name> [generated_message]" >&2
20+
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
1521
exit 1
1622
fi
23+
shift || true
1724

1825
# Optional second argument: an agent-generated commit message (used when
19-
# commit_style: conventional is configured).
20-
GENERATED_MESSAGE="${2:-}"
26+
# commit_style: conventional is configured). Prefer --message-file over
27+
# passing the message directly as a shell argument.
28+
GENERATED_MESSAGE=""
29+
while [ $# -gt 0 ]; do
30+
case "$1" in
31+
--message-file)
32+
_message_file="${2:-}"
33+
if [ -z "$_message_file" ]; then
34+
echo "[specify] Error: --message-file requires a path argument" >&2
35+
exit 1
36+
fi
37+
if [ ! -f "$_message_file" ]; then
38+
echo "[specify] Error: message file '$_message_file' not found" >&2
39+
exit 1
40+
fi
41+
GENERATED_MESSAGE="$(cat "$_message_file")"
42+
shift 2
43+
;;
44+
*)
45+
GENERATED_MESSAGE="$1"
46+
shift
47+
;;
48+
esac
49+
done
2150

2251
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
2352

@@ -55,7 +84,7 @@ _commit_style="fixed"
5584

5685
if [ -f "$_config_file" ]; then
5786
# Top-level scalar key: commit_style (fixed | conventional)
58-
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr -d '[:space:]' | tr '[:upper:]' '[:lower:]')
87+
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
5988
if [ -n "$_style_val" ]; then
6089
case "$_style_val" in
6190
fixed|conventional)
@@ -148,7 +177,7 @@ if [ "$_commit_style" = "conventional" ]; then
148177
if [ -n "$GENERATED_MESSAGE" ]; then
149178
_commit_msg="$GENERATED_MESSAGE"
150179
else
151-
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass the generated message as arg 2, or set commit_style: fixed)" >&2
180+
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
152181
exit 1
153182
fi
154183
fi

extensions/git/scripts/powershell/auto-commit.ps1

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,18 +4,39 @@
44
# Checks per-command config keys in git-config.yml before committing.
55
#
66
# Usage: auto-commit.ps1 <event_name> [generated_message]
7+
# auto-commit.ps1 <event_name> -MessageFile <path>
78
# e.g.: auto-commit.ps1 after_specify
8-
# e.g.: auto-commit.ps1 after_specify "feat: add OAuth specification" (commit_style: conventional)
9+
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
10+
#
11+
# -MessageFile is the preferred way to supply an agent-generated commit
12+
# message: it reads the message from a file instead of a shell argument,
13+
# so message content (which may contain quotes, $(...), backticks, etc.)
14+
# is never interpolated into a shell command line.
915
param(
1016
[Parameter(Position = 0, Mandatory = $true)]
1117
[string]$EventName,
1218

1319
# Optional agent-generated commit message (used when commit_style: conventional is configured).
20+
# Prefer -MessageFile over passing the message directly as a shell argument.
1421
[Parameter(Position = 1, Mandatory = $false)]
15-
[string]$GeneratedMessage = ""
22+
[string]$GeneratedMessage = "",
23+
24+
[Parameter(Mandatory = $false)]
25+
[string]$MessageFile = ""
1626
)
1727
$ErrorActionPreference = 'Stop'
1828

29+
if ($MessageFile) {
30+
if (-not (Test-Path $MessageFile -PathType Leaf)) {
31+
Write-Warning "[specify] Error: message file '$MessageFile' not found"
32+
exit 1
33+
}
34+
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
35+
if ($null -ne $GeneratedMessage) {
36+
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
37+
}
38+
}
39+
1940
function Find-ProjectRoot {
2041
param([string]$StartDir)
2142
$current = Resolve-Path $StartDir
@@ -168,7 +189,7 @@ if ($commitStyle -eq 'conventional') {
168189
if ($GeneratedMessage) {
169190
$commitMsg = $GeneratedMessage
170191
} else {
171-
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass the generated message as arg 2, or set commit_style: fixed)"
192+
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
172193
exit 1
173194
}
174195
}

tests/extensions/git/test_git_extension.py

Lines changed: 134 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1114,6 +1114,73 @@ def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
11141114
)
11151115
assert "[Spec Kit] Add specification" in log.stdout
11161116

1117+
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
1118+
"""commit_style: fixed (explicit) still uses the configured static message,
1119+
not just the absent-key default."""
1120+
project = _setup_project(tmp_path)
1121+
_write_config(project, (
1122+
"commit_style: fixed\n"
1123+
"auto_commit:\n"
1124+
" default: false\n"
1125+
" after_specify:\n"
1126+
" enabled: true\n"
1127+
' message: "[Spec Kit] Add specification"\n'
1128+
))
1129+
(project / "new-file.txt").write_text("content")
1130+
result = _run_bash(
1131+
"auto-commit.sh", project, "after_specify", "feat: this should be ignored"
1132+
)
1133+
assert result.returncode == 0
1134+
log = subprocess.run(
1135+
["git", "log", "--oneline", "-1"],
1136+
cwd=project, capture_output=True, text=True,
1137+
)
1138+
assert "[Spec Kit] Add specification" in log.stdout
1139+
assert "this should be ignored" not in log.stdout
1140+
1141+
def test_conventional_message_file_used(self, tmp_path: Path):
1142+
"""--message-file reads the generated message from a file instead of argv,
1143+
avoiding shell interpolation of agent-controlled content."""
1144+
project = _setup_project(tmp_path)
1145+
_write_config(project, (
1146+
"commit_style: conventional\n"
1147+
"auto_commit:\n"
1148+
" default: false\n"
1149+
" after_specify:\n"
1150+
" enabled: true\n"
1151+
' message: "[Spec Kit] Add specification"\n'
1152+
))
1153+
(project / "new-file.txt").write_text("content")
1154+
msg_file = tmp_path / "commit-msg.txt"
1155+
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
1156+
result = _run_bash(
1157+
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
1158+
)
1159+
assert result.returncode == 0
1160+
log = subprocess.run(
1161+
["git", "log", "--oneline", "-1"],
1162+
cwd=project, capture_output=True, text=True,
1163+
)
1164+
assert "feat: add $(dangerous) `injection` test" in log.stdout
1165+
1166+
def test_message_file_missing_fails(self, tmp_path: Path):
1167+
"""--message-file pointing at a nonexistent file fails clearly."""
1168+
project = _setup_project(tmp_path)
1169+
_write_config(project, (
1170+
"commit_style: conventional\n"
1171+
"auto_commit:\n"
1172+
" default: false\n"
1173+
" after_specify:\n"
1174+
" enabled: true\n"
1175+
))
1176+
(project / "new-file.txt").write_text("content")
1177+
missing = tmp_path / "does-not-exist.txt"
1178+
result = _run_bash(
1179+
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
1180+
)
1181+
assert result.returncode != 0
1182+
assert "not found" in result.stderr.lower()
1183+
11171184
def test_conventional_uses_generated_message(self, tmp_path: Path):
11181185
"""commit_style: conventional uses the generated_message argument as the commit message."""
11191186
project = _setup_project(tmp_path)
@@ -1332,6 +1399,73 @@ def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
13321399
)
13331400
assert "[Spec Kit] Add specification" in log.stdout
13341401

1402+
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
1403+
"""commit_style: fixed (explicit) still uses the configured static message,
1404+
not just the absent-key default."""
1405+
project = _setup_project(tmp_path)
1406+
_write_config(project, (
1407+
"commit_style: fixed\n"
1408+
"auto_commit:\n"
1409+
" default: false\n"
1410+
" after_specify:\n"
1411+
" enabled: true\n"
1412+
' message: "[Spec Kit] Add specification"\n'
1413+
))
1414+
(project / "new-file.txt").write_text("content")
1415+
result = _run_pwsh(
1416+
"auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
1417+
)
1418+
assert result.returncode == 0
1419+
log = subprocess.run(
1420+
["git", "log", "--oneline", "-1"],
1421+
cwd=project, capture_output=True, text=True,
1422+
)
1423+
assert "[Spec Kit] Add specification" in log.stdout
1424+
assert "this should be ignored" not in log.stdout
1425+
1426+
def test_conventional_message_file_used(self, tmp_path: Path):
1427+
"""-MessageFile reads the generated message from a file instead of argv,
1428+
avoiding shell interpolation of agent-controlled content."""
1429+
project = _setup_project(tmp_path)
1430+
_write_config(project, (
1431+
"commit_style: conventional\n"
1432+
"auto_commit:\n"
1433+
" default: false\n"
1434+
" after_specify:\n"
1435+
" enabled: true\n"
1436+
' message: "[Spec Kit] Add specification"\n'
1437+
))
1438+
(project / "new-file.txt").write_text("content")
1439+
msg_file = tmp_path / "commit-msg.txt"
1440+
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
1441+
result = _run_pwsh(
1442+
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
1443+
)
1444+
assert result.returncode == 0
1445+
log = subprocess.run(
1446+
["git", "log", "--oneline", "-1"],
1447+
cwd=project, capture_output=True, text=True,
1448+
)
1449+
assert "feat: add $(dangerous) `injection` test" in log.stdout
1450+
1451+
def test_message_file_missing_fails(self, tmp_path: Path):
1452+
"""-MessageFile pointing at a nonexistent file fails clearly."""
1453+
project = _setup_project(tmp_path)
1454+
_write_config(project, (
1455+
"commit_style: conventional\n"
1456+
"auto_commit:\n"
1457+
" default: false\n"
1458+
" after_specify:\n"
1459+
" enabled: true\n"
1460+
))
1461+
(project / "new-file.txt").write_text("content")
1462+
missing = tmp_path / "does-not-exist.txt"
1463+
result = _run_pwsh(
1464+
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
1465+
)
1466+
assert result.returncode != 0
1467+
assert "not found" in (result.stdout + result.stderr).lower()
1468+
13351469
def test_conventional_uses_generated_message(self, tmp_path: Path):
13361470
"""commit_style: conventional uses the generated_message argument as the commit message."""
13371471
project = _setup_project(tmp_path)

0 commit comments

Comments
 (0)