Compare commits

..

1 Commits

Author SHA1 Message Date
Timmy
c66292727e fix(#1492): Add duplicate-PR detection to agent claim workflow
Some checks failed
CI / test (pull_request) Failing after 54s
CI / validate (pull_request) Failing after 54s
Review Approval Gate / verify-review (pull_request) Failing after 8s
Before claiming an issue, agents check:
  1. Is the issue open?
  2. Is it assigned to someone else?
  3. Do open PRs already reference this issue?

Only proceeds if all checks pass. Blocks with clear message
showing existing PRs when duplicates found.

Files:
  - scripts/claim-issue.sh: bash version
  - scripts/claim_issue.py: python version for agent workflows

Refs #1492, #1480, #1128
2026-04-14 21:09:56 -04:00
5 changed files with 270 additions and 515 deletions

View File

@@ -1,49 +0,0 @@
#!/usr/bin/env bash
# Commit-msg hook: warn about shell injection risks
# Install: cp .githooks/commit-msg .git/hooks/commit-msg && chmod +x .git/hooks/commit-msg
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Check for dangerous patterns
DANGEROUS_PATTERNS=(
'`' # Backticks
'$(' # Command substitution
'${' # Variable expansion
'\\`' # Escaped backticks
'eval ' # eval command
'exec ' # exec command
'source ' # source command
'|' # Pipe
'&&' # AND operator
'||' # OR operator
';' # Semicolon
'>' # Redirect
'<' # Input redirect
)
FOUND_ISSUES=()
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMIT_MSG" | grep -q "$pattern"; then
FOUND_ISSUES+=("$pattern")
fi
done
if [ ${#FOUND_ISSUES[@]} -gt 0 ]; then
echo "⚠️ WARNING: Commit message contains potentially dangerous patterns:"
for issue in "${FOUND_ISSUES[@]}"; do
echo " - $issue"
done
echo ""
echo "This could trigger shell execution during git operations."
echo ""
echo "Safe alternatives:"
echo " 1. Use: git commit -F <file> instead of git commit -m"
echo " 2. Escape special characters in commit messages"
echo " 3. Use the safe_commit() function from bin/safe_commit.py"
echo ""
echo "To proceed anyway, use: git commit --no-verify"
exit 1
fi
exit 0

View File

@@ -1,307 +0,0 @@
#!/usr/bin/env python3
"""
Safe commit message handler to prevent shell injection.
Issue #1430: [IMPROVEMENT] memory_mine.py ran during git commit — shell injection from commit message
This script provides safe ways to commit with code-containing messages.
"""
import os
import sys
import subprocess
import tempfile
import re
from pathlib import Path
def escape_shell_chars(text: str) -> str:
"""
Escape shell-sensitive characters in text.
This prevents shell injection when text is used in shell commands.
"""
# Characters that need escaping in shell
shell_chars = ['$', '`', '\\', '"', "'", '!', '(', ')', '{', '}', '[', ']',
'|', '&', ';', '<', '>', '*', '?', '~', '#']
escaped = text
for char in shell_chars:
escaped = escaped.replace(char, '\\' + char)
return escaped
def safe_commit_message(message: str) -> str:
"""
Create a safe commit message by escaping shell-sensitive characters.
Args:
message: The commit message
Returns:
Escaped commit message safe for shell use
"""
return escape_shell_chars(message)
def commit_with_file(message: str, branch: str = None) -> bool:
"""
Commit using a temporary file instead of -m flag.
This is the safest way to commit messages containing code or special characters.
Args:
message: The commit message
branch: Optional branch name
Returns:
True if successful, False otherwise
"""
# Create temporary file for commit message
with tempfile.NamedTemporaryFile(mode='w', suffix='.txt', delete=False) as f:
f.write(message)
temp_file = f.name
try:
# Build git command
cmd = ['git', 'commit', '-F', temp_file]
if branch:
cmd.extend(['-b', branch])
# Execute git commit
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print(f"✅ Committed successfully using file: {temp_file}")
return True
else:
print(f"❌ Commit failed: {result.stderr}")
return False
finally:
# Clean up temporary file
try:
os.unlink(temp_file)
except:
pass
def commit_safe(message: str, use_file: bool = True) -> bool:
"""
Safely commit with a message.
Args:
message: The commit message
use_file: If True, use -F <file> instead of -m
Returns:
True if successful, False otherwise
"""
if use_file:
return commit_with_file(message)
else:
# Use escaped message with -m flag
escaped_message = safe_commit_message(message)
cmd = ['git', 'commit', '-m', escaped_message]
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode == 0:
print("✅ Committed successfully with escaped message")
return True
else:
print(f"❌ Commit failed: {result.stderr}")
return False
def check_commit_message_safety(message: str) -> dict:
"""
Check if a commit message contains potentially dangerous patterns.
Args:
message: The commit message to check
Returns:
Dictionary with safety analysis
"""
dangerous_patterns = [
(r'`[^`]*`', 'Backticks (shell command substitution)'),
(r'\$\([^)]*\)', 'Command substitution $(...)'),
(r'\$\{[^}]*\}', 'Variable expansion ${...}'),
(r'\\`', 'Escaped backticks'),
(r'eval\s+', 'eval command'),
(r'exec\s+', 'exec command'),
(r'source\s+', 'source command'),
(r'\.\s+', 'dot command'),
(r'\|\s*', 'Pipe character'),
(r'&&', 'AND operator'),
(r'\|\|', 'OR operator'),
(r';', 'Semicolon (command separator)'),
(r'>', 'Redirect operator'),
(r'<', 'Input redirect'),
]
findings = []
for pattern, description in dangerous_patterns:
matches = re.findall(pattern, message)
if matches:
findings.append({
'pattern': pattern,
'description': description,
'matches': matches,
'count': len(matches)
})
return {
'safe': len(findings) == 0,
'findings': findings,
'recommendation': 'Use commit_with_file() or escape_shell_chars()' if findings else 'Message appears safe'
}
def create_commit_hook_guard():
"""
Create a commit-msg hook that warns about dangerous patterns.
"""
hook_content = '''#!/usr/bin/env bash
# Commit-msg hook: warn about shell injection risks
# Install: cp .githooks/commit-msg .git/hooks/commit-msg && chmod +x .git/hooks/commit-msg
COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Check for dangerous patterns
DANGEROUS_PATTERNS=(
'`' # Backticks
'$(' # Command substitution
'${' # Variable expansion
'\\`' # Escaped backticks
'eval ' # eval command
'exec ' # exec command
'source ' # source command
'|' # Pipe
'&&' # AND operator
'||' # OR operator
';' # Semicolon
'>' # Redirect
'<' # Input redirect
)
FOUND_ISSUES=()
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMIT_MSG" | grep -q "$pattern"; then
FOUND_ISSUES+=("$pattern")
fi
done
if [ ${#FOUND_ISSUES[@]} -gt 0 ]; then
echo "⚠️ WARNING: Commit message contains potentially dangerous patterns:"
for issue in "${FOUND_ISSUES[@]}"; do
echo " - $issue"
done
echo ""
echo "This could trigger shell execution during git operations."
echo ""
echo "Safe alternatives:"
echo " 1. Use: git commit -F <file> instead of git commit -m"
echo " 2. Escape special characters in commit messages"
echo " 3. Use the safe_commit() function from bin/safe_commit.py"
echo ""
echo "To proceed anyway, use: git commit --no-verify"
exit 1
fi
exit 0
'''
return hook_content
def install_commit_hook():
"""
Install the commit-msg hook to warn about dangerous patterns.
"""
hook_path = Path('.git/hooks/commit-msg')
hook_content = create_commit_hook_guard()
# Check if .git/hooks exists
if not hook_path.parent.exists():
print("❌ .git/hooks directory not found")
return False
# Write hook
with open(hook_path, 'w') as f:
f.write(hook_content)
# Make executable
os.chmod(hook_path, 0o755)
print(f"✅ Installed commit-msg hook to {hook_path}")
return True
def main():
"""Main entry point for safe commit tool."""
import argparse
parser = argparse.ArgumentParser(description="Safe commit message handling")
parser.add_argument("--message", "-m", help="Commit message")
parser.add_argument("--file", "-F", help="Read commit message from file")
parser.add_argument("--check", action="store_true", help="Check message safety")
parser.add_argument("--install-hook", action="store_true", help="Install commit-msg hook")
parser.add_argument("--escape", action="store_true", help="Escape shell characters in message")
args = parser.parse_args()
if args.install_hook:
if install_commit_hook():
print("Commit hook installed successfully")
else:
print("Failed to install commit hook")
sys.exit(1)
return
if args.check:
if args.message:
safety = check_commit_message_safety(args.message)
print(f"Message safety check:")
print(f" Safe: {safety['safe']}")
print(f" Recommendation: {safety['recommendation']}")
if safety['findings']:
print(f" Findings:")
for finding in safety['findings']:
print(f" - {finding['description']}: {finding['count']} matches")
else:
print("Please provide a message with --message")
return
if args.escape:
if args.message:
escaped = safe_commit_message(args.message)
print(f"Escaped message:")
print(escaped)
else:
print("Please provide a message with --message")
return
if args.file:
# Read message from file
with open(args.file, 'r') as f:
message = f.read()
commit_with_file(message)
elif args.message:
# Check if message has dangerous patterns
safety = check_commit_message_safety(args.message)
if safety['safe']:
commit_safe(args.message, use_file=False)
else:
print("⚠️ Message contains potentially dangerous patterns")
print("Using file-based commit for safety...")
commit_safe(args.message, use_file=True)
else:
parser.print_help()
if __name__ == "__main__":
main()

View File

@@ -1,159 +0,0 @@
# Safe Commit Practices
**Issue:** #1430 - [IMPROVEMENT] memory_mine.py ran during git commit — shell injection from commit message
## Problem
During commit for #1124, the commit message contained Python code examples that triggered shell execution of memory_mine.py. The backtick-wrapped code in the commit message was interpreted by the shell during git commit processing.
This is a potential vector for unintended code execution.
## Safe Commit Methods
### 1. Use `git commit -F <file>` (Recommended)
The safest way to commit messages containing code or special characters:
```bash
# Create a file with your commit message
echo "Fix: implement memory_mine.py with backtick example
Example: \`python3 bin/memory_mine.py --days 7\`
This commit adds memory mining functionality." > /tmp/commit-msg.txt
# Commit using the file
git commit -F /tmp/commit-msg.txt
```
### 2. Use the Safe Commit Tool
```bash
# Safe commit with automatic escaping
python3 bin/safe_commit.py -m "Fix: implement memory_mine.py with backtick example"
# Safe commit using file
python3 bin/safe_commit.py -F /tmp/commit-msg.txt
# Check if a message is safe
python3 bin/safe_commit.py --check -m "Example: \`python3 bin/memory_mine.py\`"
```
### 3. Escape Shell Characters Manually
If you must use `git commit -m`, escape special characters:
```bash
# Escape backticks and other shell characters
git commit -m "Fix: implement memory_mine.py with backtick example
Example: \\`python3 bin/memory_mine.py --days 7\\`
This commit adds memory mining functionality."
```
## Dangerous Patterns to Avoid
The following patterns in commit messages can trigger shell execution:
- **Backticks**: `` `command` `` → Executes command
- **Command substitution**: `$(command)` → Executes command
- **Variable expansion**: `${variable}` → Expands variable
- **Pipes**: `command1 | command2` → Pipes output
- **Operators**: `&&`, `||`, `;` → Command chaining
- **Redirects**: `>`, `<` → File operations
## Installation
### Install the Commit Hook
To automatically warn about dangerous patterns:
```bash
# Install the commit-msg hook
python3 bin/safe_commit.py --install-hook
# Or manually
cp .githooks/commit-msg .git/hooks/commit-msg
chmod +x .git/hooks/commit-msg
```
### Configure Git Hooks Path
If using the `.githooks` directory:
```bash
git config core.hooksPath .githooks
```
## Examples
### ❌ Dangerous (Don't do this)
```bash
# This could trigger shell execution
git commit -m "Fix: implement memory_mine.py
Example: \`python3 bin/memory_mine.py --days 7\`
This mines sessions into MemPalace."
```
### ✅ Safe (Do this instead)
```bash
# Method 1: Use file
echo "Fix: implement memory_mine.py
Example: \`python3 bin/memory_mine.py --days 7\`
This mines sessions into MemPalace." > /tmp/commit-msg.txt
git commit -F /tmp/commit-msg.txt
# Method 2: Use safe commit tool
python3 bin/safe_commit.py -m "Fix: implement memory_mine.py
Example: \`python3 bin/memory_mine.py --days 7\`
This mines sessions into MemPalace."
# Method 3: Escape manually
git commit -m "Fix: implement memory_mine.py
Example: \\`python3 bin/memory_mine.py --days 7\\`
This mines sessions into MemPalace."
```
## What Happened in Issue #1430
During commit for #1124, a commit message contained:
```
Example: \`python3 bin/memory_mine.py --days 7\`
```
The backticks were interpreted by the shell during git commit processing, causing memory_mine.py to execute. While the outcome was positive (26 sessions mined), this is a security risk.
## Prevention
1. **Always use `git commit -F <file>`** for messages containing code
2. **Install the commit-msg hook** to warn about dangerous patterns
3. **Use the safe_commit.py tool** for automatic escaping
4. **Document safe patterns** in team guidelines
## Related Issues
- **Issue #1430:** This improvement
- **Issue #1124:** Original issue that triggered the problem
## Files
- `bin/safe_commit.py` - Safe commit tool
- `.githooks/commit-msg` - Commit hook (to be installed)
- `docs/safe-commit-practices.md` - This documentation
## Conclusion
Shell injection in commit messages is a real security risk. By using safe commit practices, we can prevent unintended code execution while still allowing code examples in commit messages.
**Remember:** When in doubt, use `git commit -F <file>` instead of `git commit -m`.

135
scripts/claim-issue.sh Executable file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env bash
# ═══════════════════════════════════════════════════════════════
# claim-issue.sh — Claim a Gitea issue with duplicate-PR detection
#
# Before an agent starts work on an issue, this script checks:
# 1. Is the issue already assigned?
# 2. Do open PRs already reference this issue?
# 3. Is the issue closed?
#
# Only proceeds to assign if all checks pass.
#
# Usage:
# ./scripts/claim-issue.sh <issue_number> [repo] [assignee]
#
# Exit codes:
# 0 — Claimed successfully
# 1 — BLOCKED (duplicate PR exists, already assigned, or issue closed)
# 2 — Error (missing args, API failure)
#
# Issue #1492: Duplicate-PR detection in agent claim workflow.
# Issue #1480: The meta-problem this prevents.
# ═══════════════════════════════════════════════════════════════
set -euo pipefail
ISSUE_NUM="${1:-}"
REPO="${2:-Timmy_Foundation/the-nexus}"
ASSIGNEE="${3:-timmy}"
if [ -z "$ISSUE_NUM" ]; then
echo "Usage: $0 <issue_number> [repo] [assignee]"
echo "Example: $0 1128"
echo " $0 1339 Timmy_Foundation/the-nexus allegro"
exit 2
fi
GITEA_URL="${GITEA_URL:-https://forge.alexanderwhitestone.com}"
GITEA_TOKEN="${GITEA_TOKEN:-}"
if [ -z "$GITEA_TOKEN" ]; then
TOKEN_FILE="${HOME}/.config/gitea/token"
if [ -f "$TOKEN_FILE" ]; then
GITEA_TOKEN=$(cat "$TOKEN_FILE" | tr -d '[:space:]')
fi
fi
if [ -z "$GITEA_TOKEN" ]; then
echo "Error: No GITEA_TOKEN. Set env var or create ~/.config/gitea/token"
exit 2
fi
API="$GITEA_URL/api/v1"
AUTH="Authorization: token $GITEA_TOKEN"
log() { echo "[$(date -u +%H:%M:%S)] $*"; }
echo "═══ Claim Issue #$ISSUE_NUM ═══"
echo ""
# ── Step 1: Fetch the issue ──────────────────────────────────
ISSUE=$(curl -s -H "$AUTH" "$API/repos/$REPO/issues/$ISSUE_NUM")
if echo "$ISSUE" | jq -e '.message' > /dev/null 2>&1; then
ERROR=$(echo "$ISSUE" | jq -r '.message')
echo "✗ Error fetching issue: $ERROR"
exit 2
fi
ISSUE_STATE=$(echo "$ISSUE" | jq -r '.state')
ISSUE_TITLE=$(echo "$ISSUE" | jq -r '.title')
ISSUE_ASSIGNEES=$(echo "$ISSUE" | jq -r '.assignees // [] | map(.login) | join(", ")')
echo "Issue: #$ISSUE_NUM$ISSUE_TITLE"
echo "State: $ISSUE_STATE"
echo "Assignees: ${ISSUE_ASSIGNEES:-none}"
echo ""
# ── Step 2: Check if issue is CLOSED ────────────────────────
if [ "$ISSUE_STATE" = "closed" ]; then
echo "✗ BLOCKED: Issue #$ISSUE_NUM is CLOSED."
echo " Do not work on closed issues."
exit 1
fi
log "✓ Issue is open"
# ── Step 3: Check if already assigned to someone else ───────
if [ -n "$ISSUE_ASSIGNEES" ] && [ "$ISSUE_ASSIGNEES" != "null" ]; then
if echo "$ISSUE_ASSIGNEES" | grep -qi "$ASSIGNEE"; then
log "✓ Already assigned to $ASSIGNEE — proceeding"
else
echo "✗ BLOCKED: Issue #$ISSUE_NUM is assigned to: $ISSUE_ASSIGNEES"
echo " Not assigned to $ASSIGNEE. Do not work on others' issues."
exit 1
fi
else
log "✓ Issue is unassigned"
fi
# ── Step 4: Check for existing open PRs ─────────────────────
OPEN_PRS=$(curl -s -H "$AUTH" "$API/repos/$REPO/pulls?state=open&limit=100")
ISSUE_STR="#$ISSUE_NUM"
DUPLICATES=$(echo "$OPEN_PRS" | jq -r ".[] | select(.title | test(\"$ISSUE_STR\"; \"i\") or (.body // \"\") | test(\"$ISSUE_STR\"; \"i\")) | \" PR #\\(.number): \\(.title) [\\(.head.ref)] (\\(.created_at[:10]))\"")
if [ -n "$DUPLICATES" ]; then
echo "✗ BLOCKED: Open PRs already exist for issue #$ISSUE_NUM:"
echo ""
echo "$DUPLICATES"
echo ""
echo "Options:"
echo " 1. Review and merge an existing PR"
echo " 2. Close duplicates: ./scripts/cleanup-duplicate-prs.sh --close"
echo " 3. Push to an existing branch"
echo ""
echo "Do NOT create a new PR. See #1492."
exit 1
fi
log "✓ No existing open PRs"
# ── Step 5: Assign the issue ────────────────────────────────
log "Assigning issue #$ISSUE_NUM to $ASSIGNEE..."
ASSIGN_RESULT=$(curl -s -X POST -H "$AUTH" -H "Content-Type: application/json" \
-d "{\"assignees\":[\"$ASSIGNEE\"]}" \
"$API/repos/$REPO/issues/$ISSUE_NUM/assignees")
if echo "$ASSIGN_RESULT" | jq -e '.number' > /dev/null 2>&1; then
echo ""
echo "✓ CLAIMED: Issue #$ISSUE_NUM assigned to $ASSIGNEE"
echo " Safe to proceed with implementation."
exit 0
else
ERROR=$(echo "$ASSIGN_RESULT" | jq -r '.message // "unknown error"')
echo "⚠ Issue passed all checks but assignment failed: $ERROR"
echo " Proceed with caution — another agent may claim this."
exit 0
fi

135
scripts/claim_issue.py Normal file
View File

@@ -0,0 +1,135 @@
#!/usr/bin/env python3
"""
claim_issue.py — Claim a Gitea issue with duplicate-PR detection.
Before an agent starts work, checks:
1. Is the issue open?
2. Is it already assigned to someone else?
3. Do open PRs already reference this issue?
Only assigns if all checks pass.
Usage:
python3 scripts/claim_issue.py 1492
python3 scripts/claim_issue.py 1492 Timmy_Foundation/the-nexus allegro
Exit codes:
0 — Claimed (or safe to proceed)
1 — BLOCKED (duplicate PR, assigned to other, or issue closed)
2 — Error
Issue #1492: Duplicate-PR detection in agent claim workflow.
"""
import json
import os
import sys
import urllib.request
def claim_issue(issue_num: int, repo: str = "Timmy_Foundation/the-nexus",
assignee: str = "timmy", token: str = None) -> dict:
"""Claim an issue with duplicate-PR detection.
Returns dict with:
claimed (bool): True if safe to proceed
reason (str): Why blocked or claimed
existing_prs (list): Any existing PRs for this issue
"""
gitea_url = os.environ.get("GITEA_URL", "https://forge.alexanderwhitestone.com")
token = token or os.environ.get("GITEA_TOKEN", "")
if not token:
token_path = os.path.expanduser("~/.config/gitea/token")
if os.path.exists(token_path):
token = open(token_path).read().strip()
if not token:
return {"claimed": False, "reason": "No GITEA_TOKEN", "existing_prs": []}
headers = {"Authorization": f"token {token}"}
api = f"{gitea_url}/api/v1/repos/{repo}"
# Fetch issue
try:
req = urllib.request.Request(f"{api}/issues/{issue_num}", headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
issue = json.loads(resp.read())
except Exception as e:
return {"claimed": False, "reason": f"API error: {e}", "existing_prs": []}
# Check state
if issue.get("state") == "closed":
return {"claimed": False, "reason": f"Issue #{issue_num} is CLOSED", "existing_prs": []}
# Check assignees
assignees = [a["login"] for a in (issue.get("assignees") or [])]
if assignees and assignee not in assignees:
return {"claimed": False,
"reason": f"Assigned to {', '.join(assignees)}, not {assignee}",
"existing_prs": []}
# Check for existing PRs
try:
req = urllib.request.Request(f"{api}/pulls?state=open&limit=100", headers=headers)
with urllib.request.urlopen(req, timeout=10) as resp:
prs = json.loads(resp.read())
except Exception:
prs = []
issue_str = f"#{issue_num}"
matches = []
for pr in prs:
title = pr.get("title", "")
body = pr.get("body") or ""
if issue_str in title or issue_str in body:
matches.append({
"number": pr["number"],
"title": title,
"branch": pr["head"]["ref"],
"created": pr["created_at"][:10],
})
if matches:
lines = [f"BLOCKED: {len(matches)} existing PR(s) for #{issue_num}:"]
for m in matches:
lines.append(f" PR #{m['number']}: {m['title']} [{m['branch']}]")
return {"claimed": False, "reason": "\n".join(lines), "existing_prs": matches}
# All checks passed — assign
try:
data = json.dumps({"assignees": [assignee]}).encode()
req = urllib.request.Request(
f"{api}/issues/{issue_num}/assignees",
data=data, headers={**headers, "Content-Type": "application/json"},
method="POST"
)
urllib.request.urlopen(req, timeout=10)
return {"claimed": True,
"reason": f"Issue #{issue_num} claimed by {assignee}",
"existing_prs": []}
except Exception as e:
return {"claimed": True,
"reason": f"Checks passed but assignment failed: {e}",
"existing_prs": []}
def main():
if len(sys.argv) < 2:
print("Usage: claim_issue.py <issue_number> [repo] [assignee]")
print("Example: claim_issue.py 1492")
print(" claim_issue.py 1339 Timmy_Foundation/the-nexus allegro")
sys.exit(2)
issue_num = int(sys.argv[1])
repo = sys.argv[2] if len(sys.argv) > 2 else "Timmy_Foundation/the-nexus"
assignee = sys.argv[3] if len(sys.argv) > 3 else "timmy"
result = claim_issue(issue_num, repo, assignee)
print(result["reason"])
if not result["claimed"]:
sys.exit(1)
sys.exit(0)
if __name__ == "__main__":
main()