mirror of
https://github.com/anthropics/claude-code.git
synced 2026-02-19 04:27:33 -08:00
Compare commits
14 Commits
claude/sla
...
claude/sla
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0d15fae207 | ||
|
|
5c92b97cc4 | ||
|
|
d213a74fc8 | ||
|
|
52115592ba | ||
|
|
5d2df70860 | ||
|
|
b8a2ffb38f | ||
|
|
9fd556d947 | ||
|
|
b31e2fd182 | ||
|
|
d2cb503247 | ||
|
|
5fe61207ff | ||
|
|
1ed82e6af0 | ||
|
|
2a61cb364c | ||
|
|
6880bcbace | ||
|
|
4392352687 |
177
.claude/bash-permission-bug-analysis.md
Normal file
177
.claude/bash-permission-bug-analysis.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Bash Permission Serialization Bug Analysis
|
||||
|
||||
## Bug Description
|
||||
|
||||
When Claude Code saves bash permission rules to `settings.local.json`, malformed commands with unbalanced quotes can be stored. The validation that detects malformed patterns only runs when **loading** settings, not when **saving** them.
|
||||
|
||||
### Reproduction Steps
|
||||
1. Have Claude run a bash command containing quotes (e.g., arithmetic with quoted strings)
|
||||
2. When permission is requested, click "Always Allow"
|
||||
3. A malformed permission rule may be saved to `settings.local.json`
|
||||
4. On next Claude Code startup, the settings fail to load with error:
|
||||
```
|
||||
"Bash(250/0...
|
||||
└ 029 ")": Unmatched " in Bash pattern. Ensure all quotes are properly paired
|
||||
```
|
||||
|
||||
### Example Malformed Rule
|
||||
```json
|
||||
{
|
||||
"permissions": {
|
||||
"allow": ["Bash(250/0.029 \")"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
### Code Flow
|
||||
|
||||
1. **Permission Check** (`OE0` function):
|
||||
- Parses the bash command
|
||||
- Creates suggestions via `UE0(command)` or `im5(prefix)`
|
||||
- Returns suggestions to be shown in permission dialog
|
||||
|
||||
2. **Suggestion Creation** (`UE0` function):
|
||||
```javascript
|
||||
function UE0(A){
|
||||
return[{
|
||||
type:"addRules",
|
||||
rules:[{toolName:M9.name, ruleContent:A}],
|
||||
behavior:"allow",
|
||||
destination:"localSettings"
|
||||
}]
|
||||
}
|
||||
```
|
||||
- Takes command string directly as `ruleContent`
|
||||
- **No validation** that the command is well-formed
|
||||
|
||||
3. **Saving Rules** (`_A1` function):
|
||||
```javascript
|
||||
function _A1({ruleValues:A, ruleBehavior:Q}, B){
|
||||
let G = A.map(s5); // Serialize rules
|
||||
// ... save to settings
|
||||
nB(B, W); // No validation before save
|
||||
}
|
||||
```
|
||||
|
||||
4. **Serialization** (`s5` function):
|
||||
```javascript
|
||||
function s5(A){
|
||||
if(!A.ruleContent) return A.toolName;
|
||||
let Q = HJ7(A.ruleContent); // Escape parens/backslashes
|
||||
return `${A.toolName}(${Q})`
|
||||
}
|
||||
```
|
||||
- Only escapes `\`, `(`, `)`
|
||||
- Does **not** validate quotes are balanced
|
||||
|
||||
5. **Validation** (only on load):
|
||||
```javascript
|
||||
let X = ['"', "'"];
|
||||
for(let W of X)
|
||||
if((J.match(new RegExp(W,"g"))||[]).length % 2 !== 0)
|
||||
return {
|
||||
valid: false,
|
||||
error: `Unmatched ${W} in Bash pattern`,
|
||||
suggestion: "Ensure all quotes are properly paired"
|
||||
};
|
||||
```
|
||||
|
||||
### The Gap
|
||||
|
||||
Validation exists but is only called when **parsing** settings, not when **creating** permission rules. This allows:
|
||||
- Malformed commands from the model to be saved
|
||||
- Commands with shell parsing errors to be saved
|
||||
- Truncated or incorrectly reconstructed commands to be saved
|
||||
|
||||
## Proposed Fix
|
||||
|
||||
### Option 1: Validate Before Saving (Recommended)
|
||||
|
||||
Add validation in `_A1` before saving:
|
||||
|
||||
```javascript
|
||||
function _A1({ruleValues: A, ruleBehavior: Q}, B) {
|
||||
if (A.length < 1) return true;
|
||||
|
||||
// Validate each rule before saving
|
||||
for (const rule of A) {
|
||||
const serialized = s5(rule);
|
||||
const validation = validatePermissionRule(serialized);
|
||||
if (!validation.valid) {
|
||||
console.error(`Invalid permission rule: ${validation.error}`);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
let G = A.map(s5);
|
||||
// ... rest of function
|
||||
}
|
||||
```
|
||||
|
||||
### Option 2: Validate in UE0/im5
|
||||
|
||||
Add validation when creating suggestions:
|
||||
|
||||
```javascript
|
||||
function UE0(A) {
|
||||
// Validate command has balanced quotes
|
||||
const doubleQuotes = (A.match(/"/g) || []).length;
|
||||
const singleQuotes = (A.match(/'/g) || []).length;
|
||||
|
||||
if (doubleQuotes % 2 !== 0 || singleQuotes % 2 !== 0) {
|
||||
// Don't create suggestion for malformed command
|
||||
return [];
|
||||
}
|
||||
|
||||
return [{
|
||||
type: "addRules",
|
||||
rules: [{toolName: M9.name, ruleContent: A}],
|
||||
behavior: "allow",
|
||||
destination: "localSettings"
|
||||
}];
|
||||
}
|
||||
```
|
||||
|
||||
### Option 3: Sanitize Command Before Creating Rule
|
||||
|
||||
If the command has unbalanced quotes, try to fix or reject it:
|
||||
|
||||
```javascript
|
||||
function sanitizeCommandForRule(command) {
|
||||
// Check for balanced quotes
|
||||
const dq = (command.match(/"/g) || []).length;
|
||||
const sq = (command.match(/'/g) || []).length;
|
||||
|
||||
if (dq % 2 !== 0 || sq % 2 !== 0) {
|
||||
// Log warning and return null to skip this rule
|
||||
console.warn(`Skipping malformed command for permission rule: ${command}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return command;
|
||||
}
|
||||
```
|
||||
|
||||
## Additional Observations
|
||||
|
||||
1. The command parsing/reconstruction in `VJ7` is complex and may have edge cases where commands are not properly reconstructed
|
||||
|
||||
2. The `KE0` function uses quote markers (`__DOUBLE_QUOTE__`, `__SINGLE_QUOTE__`) for parsing which may not be fully restored in all cases
|
||||
|
||||
3. For piped commands, each segment is processed separately, increasing the chance of parsing errors
|
||||
|
||||
## Recommended Fix Priority
|
||||
|
||||
1. **Immediate**: Add validation before saving in `_A1` or `$v` functions
|
||||
2. **Short-term**: Add better error handling when command parsing fails
|
||||
3. **Long-term**: Review command reconstruction logic in `VJ7` for edge cases
|
||||
|
||||
## Related CHANGELOG Entries
|
||||
|
||||
- "Fixed permission rules incorrectly rejecting valid bash commands containing shell glob patterns"
|
||||
- "Fixed Bash tool crashes caused by malformed shell syntax parsing"
|
||||
- "Fixed security vulnerability in Bash tool permission checks"
|
||||
|
||||
These suggest this area of the code has had issues before.
|
||||
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,3 +1,2 @@
|
||||
.DS_Store
|
||||
__pycache__/
|
||||
*.pyc
|
||||
|
||||
|
||||
56
CHANGELOG.md
56
CHANGELOG.md
@@ -1,11 +1,65 @@
|
||||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
- Fixed bash permission rules with unbalanced quotes being saved to settings, causing settings to fail validation on load
|
||||
|
||||
## 2.0.74
|
||||
|
||||
- Added LSP (Language Server Protocol) tool for code intelligence features like go-to-definition, find references, and hover documentation
|
||||
- Added `/terminal-setup` support for Kitty, Alacritty, Zed, and Warp terminals
|
||||
- Added ctrl+t shortcut in `/theme` to toggle syntax highlighting on/off
|
||||
- Added syntax highlighting info to theme picker
|
||||
- Added guidance for macOS users when Alt shortcuts fail due to terminal configuration
|
||||
- Fixed skill `allowed-tools` not being applied to tools invoked by the skill
|
||||
- Fixed Opus 4.5 tip incorrectly showing when user was already using Opus
|
||||
- Fixed a potential crash when syntax highlighting isn't initialized correctly
|
||||
- Fixed visual bug in `/plugins discover` where list selection indicator showed while search box was focused
|
||||
- Fixed macOS keyboard shortcuts to display 'opt' instead of 'alt'
|
||||
- Improved `/context` command visualization with grouped skills and agents by source, slash commands, and sorted token count
|
||||
- [Windows] Fixed issue with improper rendering
|
||||
- [VSCode] Added gift tag pictogram for year-end promotion message
|
||||
|
||||
## 2.0.73
|
||||
|
||||
- Added clickable `[Image #N]` links that open attached images in the default viewer
|
||||
- Added alt-y yank-pop to cycle through kill ring history after ctrl-y yank
|
||||
- Added search filtering to the plugin discover screen (type to filter by name, description, or marketplace)
|
||||
- Added support for custom session IDs when forking sessions with `--session-id` combined with `--resume` or `--continue` and `--fork-session`
|
||||
- Fixed slow input history cycling and race condition that could overwrite text after message submission
|
||||
- Improved `/theme` command to open theme picker directly
|
||||
- Improved theme picker UI
|
||||
- Improved search UX across resume session, permissions, and plugins screens with a unified SearchBox component
|
||||
- [VSCode] Added tab icon badges showing pending permissions (blue) and unread completions (orange)
|
||||
|
||||
## 2.0.72
|
||||
|
||||
- Added Claude in Chrome (Beta) feature that works with the Chrome extension (https://claude.ai/chrome) to let you control your browser directly from Claude Code
|
||||
- Reduced terminal flickering
|
||||
- Added scannable QR code to mobile app tip for quick app downloads
|
||||
- Added loading indicator when resuming conversations for better feedback
|
||||
- Fixed `/context` command not respecting custom system prompts in non-interactive mode
|
||||
- Fixed order of consecutive Ctrl+K lines when pasting with Ctrl+Y
|
||||
- Improved @ mention file suggestion speed (~3x faster in git repositories)
|
||||
- Improved file suggestion performance in repos with `.ignore` or `.rgignore` files
|
||||
- Improved settings validation errors to be more prominent
|
||||
- Changed thinking toggle from Tab to Alt+T to avoid accidental triggers
|
||||
|
||||
## 2.0.71
|
||||
|
||||
- Added /config toggle to enable/disable prompt suggestions
|
||||
- Added `/settings` as an alias for the `/config` command
|
||||
- Fixed @ file reference suggestions incorrectly triggering when cursor is in the middle of a path
|
||||
- Fixed MCP servers from `.mcp.json` not loading when using `--dangerously-skip-permissions`
|
||||
- Fixed permission rules incorrectly rejecting valid bash commands containing shell glob patterns (e.g., `ls *.txt`, `for f in *.png`)
|
||||
- Bedrock: Environment variable `ANTHROPIC_BEDROCK_BASE_URL` is now respected for token counting and inference profile listing
|
||||
- New syntax highlighting engine for native build
|
||||
|
||||
## 2.0.70
|
||||
|
||||
- Added Enter key to accept and submit prompt suggestions immediately (tab still accepts for editing)
|
||||
- Added wildcard syntax `mcp__server__*` for MCP tool permissions to allow or deny all tools from a server
|
||||
- Added auto-update toggle for plugin marketplaces, allowing per-marketplace control over automatic updates
|
||||
- Added `plan_mode_required` spawn parameter for teammates to require plan approval before implementing changes
|
||||
- Added `current_usage` field to status line input, enabling accurate context window percentage calculations
|
||||
- Fixed input being cleared when processing queued commands while the user was typing
|
||||
- Fixed prompt suggestions replacing typed input when pressing Tab
|
||||
|
||||
@@ -1,163 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Claude Code Hook: Fix File Permissions After Write
|
||||
===================================================
|
||||
This hook runs as a PostToolUse hook for the Write and Edit tools.
|
||||
It fixes file permissions to respect the system's umask setting.
|
||||
|
||||
This addresses the issue where Claude Code's Write tool creates files with
|
||||
restrictive 0600 permissions, ignoring the user's umask setting.
|
||||
|
||||
Read more about hooks here: https://docs.anthropic.com/en/docs/claude-code/hooks
|
||||
|
||||
Configuration example for ~/.claude/settings.json or .claude/settings.local.json:
|
||||
|
||||
{
|
||||
"hooks": {
|
||||
"PostToolUse": [
|
||||
{
|
||||
"matcher": "Write|Edit",
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "python3 /path/to/claude-code/examples/hooks/fix_file_permissions_example.py"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
How it works:
|
||||
- After Write or Edit tool completes, this hook runs
|
||||
- Gets the file path from the tool input
|
||||
- Calculates the correct permissions based on the current umask
|
||||
- Applies the umask-respecting permissions to the file
|
||||
|
||||
For example:
|
||||
- With umask 022: files become 0644 (rw-r--r--)
|
||||
- With umask 002: files become 0664 (rw-rw-r--)
|
||||
- With umask 077: files remain 0600 (rw-------)
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import sys
|
||||
|
||||
|
||||
def get_umask() -> int:
|
||||
"""Get the current umask value.
|
||||
|
||||
We temporarily set umask to get the current value, then restore it.
|
||||
This is the standard way to read umask in Python.
|
||||
"""
|
||||
current_umask = os.umask(0)
|
||||
os.umask(current_umask)
|
||||
return current_umask
|
||||
|
||||
|
||||
def calculate_file_permissions(umask_value: int) -> int:
|
||||
"""Calculate file permissions based on umask.
|
||||
|
||||
Standard Unix behavior: new files start with 0666 base permissions,
|
||||
then umask is applied to remove bits.
|
||||
|
||||
Args:
|
||||
umask_value: The current umask value (e.g., 0o022)
|
||||
|
||||
Returns:
|
||||
The file permissions after applying umask (e.g., 0o644)
|
||||
"""
|
||||
base_permissions = 0o666 # rw-rw-rw-
|
||||
return base_permissions & ~umask_value
|
||||
|
||||
|
||||
def fix_file_permissions(file_path: str) -> dict:
|
||||
"""Fix permissions for a file to respect umask.
|
||||
|
||||
Args:
|
||||
file_path: Path to the file to fix
|
||||
|
||||
Returns:
|
||||
Dict with status information
|
||||
"""
|
||||
if not file_path:
|
||||
return {"status": "skipped", "reason": "no file path provided"}
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
return {"status": "skipped", "reason": "file does not exist"}
|
||||
|
||||
if not os.path.isfile(file_path):
|
||||
return {"status": "skipped", "reason": "path is not a file"}
|
||||
|
||||
try:
|
||||
# Get current permissions
|
||||
current_mode = stat.S_IMODE(os.stat(file_path).st_mode)
|
||||
|
||||
# Calculate expected permissions based on umask
|
||||
umask_value = get_umask()
|
||||
expected_mode = calculate_file_permissions(umask_value)
|
||||
|
||||
# Only change if current permissions are more restrictive than expected
|
||||
# This handles the case where Write tool sets 0600 instead of umask-based perms
|
||||
if current_mode == 0o600 and expected_mode != 0o600:
|
||||
os.chmod(file_path, expected_mode)
|
||||
return {
|
||||
"status": "fixed",
|
||||
"file": file_path,
|
||||
"old_mode": oct(current_mode),
|
||||
"new_mode": oct(expected_mode),
|
||||
"umask": oct(umask_value),
|
||||
}
|
||||
else:
|
||||
return {
|
||||
"status": "unchanged",
|
||||
"file": file_path,
|
||||
"current_mode": oct(current_mode),
|
||||
"expected_mode": oct(expected_mode),
|
||||
}
|
||||
|
||||
except PermissionError as e:
|
||||
return {"status": "error", "reason": f"permission denied: {e}"}
|
||||
except OSError as e:
|
||||
return {"status": "error", "reason": f"OS error: {e}"}
|
||||
|
||||
|
||||
def main():
|
||||
"""Main entry point for the PostToolUse hook."""
|
||||
try:
|
||||
input_data = json.load(sys.stdin)
|
||||
except json.JSONDecodeError as e:
|
||||
# Exit 0 - don't block on invalid input, just log to stderr
|
||||
print(f"Warning: Invalid JSON input: {e}", file=sys.stderr)
|
||||
sys.exit(0)
|
||||
|
||||
tool_name = input_data.get("tool_name", "")
|
||||
|
||||
# Only process Write and Edit tools
|
||||
if tool_name not in ("Write", "Edit"):
|
||||
sys.exit(0)
|
||||
|
||||
tool_input = input_data.get("tool_input", {})
|
||||
file_path = tool_input.get("file_path", "")
|
||||
|
||||
if not file_path:
|
||||
sys.exit(0)
|
||||
|
||||
result = fix_file_permissions(file_path)
|
||||
|
||||
# Output result as JSON for logging/debugging
|
||||
# This will appear in the transcript when running with --debug
|
||||
if result.get("status") == "fixed":
|
||||
output = {
|
||||
"systemMessage": f"Fixed file permissions for {file_path}: {result['old_mode']} -> {result['new_mode']} (umask: {result['umask']})"
|
||||
}
|
||||
print(json.dumps(output))
|
||||
|
||||
# Always exit 0 - this is a PostToolUse hook, we don't want to block
|
||||
sys.exit(0)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,278 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests for the fix_file_permissions_example.py hook.
|
||||
|
||||
Run these tests with:
|
||||
python3 examples/hooks/test_fix_file_permissions.py
|
||||
|
||||
Or with pytest:
|
||||
pytest examples/hooks/test_fix_file_permissions.py -v
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
# Path to the hook script
|
||||
HOOK_SCRIPT = Path(__file__).parent / "fix_file_permissions_example.py"
|
||||
|
||||
|
||||
class TestFixFilePermissionsHook(unittest.TestCase):
|
||||
"""Test cases for the file permissions fix hook."""
|
||||
|
||||
def setUp(self):
|
||||
"""Set up test fixtures."""
|
||||
self.temp_dir = tempfile.mkdtemp()
|
||||
self.test_file = os.path.join(self.temp_dir, "test_file.txt")
|
||||
|
||||
def tearDown(self):
|
||||
"""Clean up test files."""
|
||||
if os.path.exists(self.test_file):
|
||||
os.remove(self.test_file)
|
||||
if os.path.exists(self.temp_dir):
|
||||
os.rmdir(self.temp_dir)
|
||||
|
||||
def run_hook(self, tool_name: str, file_path: str) -> subprocess.CompletedProcess:
|
||||
"""Run the hook script with given input."""
|
||||
input_data = {
|
||||
"tool_name": tool_name,
|
||||
"tool_input": {"file_path": file_path},
|
||||
"session_id": "test-session",
|
||||
"cwd": os.getcwd(),
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(HOOK_SCRIPT)],
|
||||
input=json.dumps(input_data),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result
|
||||
|
||||
def create_file_with_permissions(self, path: str, mode: int) -> None:
|
||||
"""Create a test file with specific permissions."""
|
||||
with open(path, "w") as f:
|
||||
f.write("test content")
|
||||
os.chmod(path, mode)
|
||||
|
||||
def get_file_permissions(self, path: str) -> int:
|
||||
"""Get the permission bits of a file."""
|
||||
return stat.S_IMODE(os.stat(path).st_mode)
|
||||
|
||||
def test_fixes_restrictive_permissions_with_umask_022(self):
|
||||
"""Test that 0600 permissions are fixed to 0644 with umask 022."""
|
||||
# Save and set umask
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
# Create file with restrictive permissions (simulating Write tool bug)
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
||||
|
||||
# Run the hook
|
||||
result = self.run_hook("Write", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# Check permissions were fixed
|
||||
expected_mode = 0o644 # 0666 & ~0022
|
||||
self.assertEqual(
|
||||
self.get_file_permissions(self.test_file),
|
||||
expected_mode,
|
||||
f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_fixes_restrictive_permissions_with_umask_002(self):
|
||||
"""Test that 0600 permissions are fixed to 0664 with umask 002."""
|
||||
# Save and set umask
|
||||
old_umask = os.umask(0o002)
|
||||
try:
|
||||
# Create file with restrictive permissions
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
||||
|
||||
# Run the hook
|
||||
result = self.run_hook("Write", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# Check permissions were fixed
|
||||
expected_mode = 0o664 # 0666 & ~0002
|
||||
self.assertEqual(
|
||||
self.get_file_permissions(self.test_file),
|
||||
expected_mode,
|
||||
f"Expected {oct(expected_mode)}, got {oct(self.get_file_permissions(self.test_file))}",
|
||||
)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_preserves_permissions_matching_umask(self):
|
||||
"""Test that permissions already matching umask are not changed."""
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
# Create file with correct permissions already
|
||||
self.create_file_with_permissions(self.test_file, 0o644)
|
||||
|
||||
# Run the hook
|
||||
result = self.run_hook("Write", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# Permissions should be unchanged
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_respects_umask_077(self):
|
||||
"""Test that umask 077 results in 0600 (no change needed)."""
|
||||
old_umask = os.umask(0o077)
|
||||
try:
|
||||
# Create file with 0600 permissions
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
|
||||
# Run the hook
|
||||
result = self.run_hook("Write", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# With umask 077, 0600 is correct - should remain unchanged
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_handles_edit_tool(self):
|
||||
"""Test that the hook also works for the Edit tool."""
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
|
||||
result = self.run_hook("Edit", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o644)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_ignores_other_tools(self):
|
||||
"""Test that the hook ignores non-Write/Edit tools."""
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
|
||||
result = self.run_hook("Read", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# Permissions should be unchanged for Read tool
|
||||
self.assertEqual(self.get_file_permissions(self.test_file), 0o600)
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
def test_handles_nonexistent_file(self):
|
||||
"""Test that the hook handles non-existent files gracefully."""
|
||||
result = self.run_hook("Write", "/nonexistent/path/file.txt")
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_handles_empty_file_path(self):
|
||||
"""Test that the hook handles empty file path gracefully."""
|
||||
input_data = {
|
||||
"tool_name": "Write",
|
||||
"tool_input": {},
|
||||
"session_id": "test-session",
|
||||
}
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(HOOK_SCRIPT)],
|
||||
input=json.dumps(input_data),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_handles_invalid_json(self):
|
||||
"""Test that the hook handles invalid JSON input gracefully."""
|
||||
result = subprocess.run(
|
||||
[sys.executable, str(HOOK_SCRIPT)],
|
||||
input="not valid json",
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
# Should exit 0 even with invalid input (don't block the workflow)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
self.assertIn("Invalid JSON", result.stderr)
|
||||
|
||||
def test_handles_directory_path(self):
|
||||
"""Test that the hook ignores directory paths."""
|
||||
result = self.run_hook("Write", self.temp_dir)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
def test_outputs_system_message_on_fix(self):
|
||||
"""Test that the hook outputs a systemMessage when fixing permissions."""
|
||||
old_umask = os.umask(0o022)
|
||||
try:
|
||||
self.create_file_with_permissions(self.test_file, 0o600)
|
||||
|
||||
result = self.run_hook("Write", self.test_file)
|
||||
self.assertEqual(result.returncode, 0)
|
||||
|
||||
# Check that stdout contains the systemMessage JSON
|
||||
if result.stdout.strip():
|
||||
output = json.loads(result.stdout)
|
||||
self.assertIn("systemMessage", output)
|
||||
self.assertIn("Fixed file permissions", output["systemMessage"])
|
||||
finally:
|
||||
os.umask(old_umask)
|
||||
|
||||
|
||||
class TestCalculateFilePermissions(unittest.TestCase):
|
||||
"""Test the calculate_file_permissions function directly."""
|
||||
|
||||
def test_umask_022(self):
|
||||
"""Test permission calculation with umask 022."""
|
||||
# Import the function from the hook script
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
||||
hook = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hook)
|
||||
|
||||
result = hook.calculate_file_permissions(0o022)
|
||||
self.assertEqual(result, 0o644)
|
||||
|
||||
def test_umask_002(self):
|
||||
"""Test permission calculation with umask 002."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
||||
hook = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hook)
|
||||
|
||||
result = hook.calculate_file_permissions(0o002)
|
||||
self.assertEqual(result, 0o664)
|
||||
|
||||
def test_umask_077(self):
|
||||
"""Test permission calculation with umask 077."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
||||
hook = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hook)
|
||||
|
||||
result = hook.calculate_file_permissions(0o077)
|
||||
self.assertEqual(result, 0o600)
|
||||
|
||||
def test_umask_000(self):
|
||||
"""Test permission calculation with umask 000."""
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("hook", HOOK_SCRIPT)
|
||||
hook = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(hook)
|
||||
|
||||
result = hook.calculate_file_permissions(0o000)
|
||||
self.assertEqual(result, 0o666)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,5 +1,5 @@
|
||||
---
|
||||
allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*)
|
||||
allowed-tools: Bash(gh issue view:*), Bash(gh search:*), Bash(gh issue list:*), Bash(gh pr comment:*), Bash(gh pr diff:*), Bash(gh pr view:*), Bash(gh pr list:*), mcp__github_inline_comment__create_inline_comment
|
||||
description: Code review a pull request
|
||||
---
|
||||
|
||||
@@ -11,7 +11,7 @@ To do this, follow these steps precisely:
|
||||
- The pull request is closed
|
||||
- The pull request is a draft
|
||||
- The pull request does not need code review (e.g. automated PR, trivial change that is obviously correct)
|
||||
- You have already submitted a code review on this pull request
|
||||
- Claude has already commented on this PR (check `gh pr view <PR> --comments` for comments left by claude)
|
||||
|
||||
If any condition is true, stop and do not proceed.
|
||||
|
||||
@@ -52,14 +52,30 @@ Note: Still review Claude generated PR's.
|
||||
|
||||
6. Filter out any issues that were not validated in step 5. This step will give us our list of high signal issues for our review.
|
||||
|
||||
7. Finally, output the review.
|
||||
- If the `--comment` argument is provided, post the review as a comment on the pull request using `gh pr comment`
|
||||
- Otherwise (default), output the review directly to the terminal for local viewing
|
||||
When writing your comment, follow these guidelines:
|
||||
a. Keep your output brief
|
||||
b. Avoid emojis
|
||||
c. Link and cite relevant code, files, and URLs for each issue
|
||||
d. When citing CLAUDE.md violations, you MUST quote the exact text from CLAUDE.md that is being violated (e.g., CLAUDE.md says: "Use snake_case for variable names")
|
||||
7. If issues were found, skip to step 8 to post inline comments directly.
|
||||
|
||||
If NO issues were found, post a summary comment using `gh pr comment` (if `--comment` argument is provided):
|
||||
"No issues found. Checked for bugs and CLAUDE.md compliance."
|
||||
|
||||
8. Post inline comments for each issue using `mcp__github_inline_comment__create_inline_comment`:
|
||||
- `path`: the file path
|
||||
- `line` (and `startLine` for ranges): select the buggy lines so the user sees them
|
||||
- `body`: Brief description of the issue (no "Bug:" prefix). For small fixes (up to 5 lines changed), include a committable suggestion:
|
||||
```suggestion
|
||||
corrected code here
|
||||
```
|
||||
|
||||
**Suggestions must be COMPLETE.** If a fix requires additional changes elsewhere (e.g., renaming a variable requires updating all usages), do NOT use a suggestion block. The author should be able to click "Commit suggestion" and have a working fix - no followup work required.
|
||||
|
||||
For larger fixes (6+ lines, structural changes, or changes spanning multiple locations), do NOT use suggestion blocks. Instead:
|
||||
1. Describe what the issue is
|
||||
2. Explain the suggested fix at a high level
|
||||
3. Include a copyable prompt for Claude Code that the user can use to fix the issue, formatted as:
|
||||
```
|
||||
Fix [file:line]: [brief description of issue and suggested fix]
|
||||
```
|
||||
|
||||
**IMPORTANT: Only post ONE comment per unique issue. Do not post duplicate comments.**
|
||||
|
||||
Use this list when evaluating issues in Steps 4 and 5 (these are false positives, do NOT flag):
|
||||
|
||||
@@ -74,40 +90,18 @@ Notes:
|
||||
|
||||
- Use gh CLI to interact with GitHub (e.g., fetch pull requests, create comments). Do not use web fetch.
|
||||
- Create a todo list before starting.
|
||||
- You must cite and link each issue (e.g., if referring to a CLAUDE.md, include a link to it).
|
||||
- For your final comment, follow the following format precisely (assuming for this example that you found 3 issues):
|
||||
- You must cite and link each issue in inline comments (e.g., if referring to a CLAUDE.md, include a link to it).
|
||||
- If no issues are found, post a comment with the following format:
|
||||
|
||||
---
|
||||
|
||||
## Code review
|
||||
|
||||
Found 3 issues:
|
||||
|
||||
1. <brief description of bug> (CLAUDE.md says: "<exact quote from CLAUDE.md>")
|
||||
|
||||
<link to file and line with full sha1 + line range for context, eg. https://github.com/anthropics/claude-code/blob/1d54823877c4de72b2316a64032a54afc404e619/README.md#L13-L17>
|
||||
|
||||
2. <brief description of bug> (some/other/CLAUDE.md says: "<exact quote from CLAUDE.md>")
|
||||
|
||||
<link to file and line with full sha1 + line range for context>
|
||||
|
||||
3. <brief description of bug> (bug due to <file and code snippet>)
|
||||
|
||||
<link to file and line with full sha1 + line range for context>
|
||||
|
||||
---
|
||||
|
||||
- Or, if you found no issues:
|
||||
|
||||
---
|
||||
|
||||
## Auto code review
|
||||
|
||||
No issues found. Checked for bugs and CLAUDE.md compliance.
|
||||
|
||||
---
|
||||
|
||||
- When linking to code, follow the following format precisely, otherwise the Markdown preview won't render correctly: https://github.com/anthropics/claude-code/blob/c21d3c10bc8e898b7ac1a2d745bdc9bc4e423afe/package.json#L10-L15
|
||||
- When linking to code in inline comments, follow the following format precisely, otherwise the Markdown preview won't render correctly: https://github.com/anthropics/claude-code/blob/c21d3c10bc8e898b7ac1a2d745bdc9bc4e423afe/package.json#L10-L15
|
||||
- Requires full git sha
|
||||
- You must provide the full sha. Commands like `https://github.com/owner/repo/blob/$(git rev-parse HEAD)/foo/bar` will not work, since your comment will be directly rendered in Markdown.
|
||||
- Repo name must match the repo you're code reviewing
|
||||
|
||||
@@ -1,26 +1,18 @@
|
||||
---
|
||||
description: "Cancel active Ralph Wiggum loop"
|
||||
allowed-tools: ["Bash"]
|
||||
allowed-tools: ["Bash(test -f .claude/ralph-loop.local.md:*)", "Bash(rm .claude/ralph-loop.local.md)", "Read(.claude/ralph-loop.local.md)"]
|
||||
hide-from-slash-command-tool: "true"
|
||||
---
|
||||
|
||||
# Cancel Ralph
|
||||
|
||||
```!
|
||||
if [[ -f .claude/ralph-loop.local.md ]]; then
|
||||
ITERATION=$(grep '^iteration:' .claude/ralph-loop.local.md | sed 's/iteration: *//')
|
||||
echo "FOUND_LOOP=true"
|
||||
echo "ITERATION=$ITERATION"
|
||||
else
|
||||
echo "FOUND_LOOP=false"
|
||||
fi
|
||||
```
|
||||
To cancel the Ralph loop:
|
||||
|
||||
Check the output above:
|
||||
1. Check if `.claude/ralph-loop.local.md` exists using Bash: `test -f .claude/ralph-loop.local.md && echo "EXISTS" || echo "NOT_FOUND"`
|
||||
|
||||
1. **If FOUND_LOOP=false**:
|
||||
- Say "No active Ralph loop found."
|
||||
2. **If NOT_FOUND**: Say "No active Ralph loop found."
|
||||
|
||||
2. **If FOUND_LOOP=true**:
|
||||
- Use Bash: `rm .claude/ralph-loop.local.md`
|
||||
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the ITERATION value from above.
|
||||
3. **If EXISTS**:
|
||||
- Read `.claude/ralph-loop.local.md` to get the current iteration number from the `iteration:` field
|
||||
- Remove the file using Bash: `rm .claude/ralph-loop.local.md`
|
||||
- Report: "Cancelled Ralph loop (was at iteration N)" where N is the iteration value
|
||||
|
||||
@@ -11,36 +11,6 @@ Execute the setup script to initialize the Ralph loop:
|
||||
|
||||
```!
|
||||
"${CLAUDE_PLUGIN_ROOT}/scripts/setup-ralph-loop.sh" $ARGUMENTS
|
||||
|
||||
# Extract and display completion promise if set
|
||||
if [ -f .claude/ralph-loop.local.md ]; then
|
||||
PROMISE=$(grep '^completion_promise:' .claude/ralph-loop.local.md | sed 's/completion_promise: *//' | sed 's/^"\(.*\)"$/\1/')
|
||||
if [ -n "$PROMISE" ] && [ "$PROMISE" != "null" ]; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "CRITICAL - Ralph Loop Completion Promise"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "To complete this loop, output this EXACT text:"
|
||||
echo " <promise>$PROMISE</promise>"
|
||||
echo ""
|
||||
echo "STRICT REQUIREMENTS (DO NOT VIOLATE):"
|
||||
echo " ✓ Use <promise> XML tags EXACTLY as shown above"
|
||||
echo " ✓ The statement MUST be completely and unequivocally TRUE"
|
||||
echo " ✓ Do NOT output false statements to exit the loop"
|
||||
echo " ✓ Do NOT lie even if you think you should exit"
|
||||
echo ""
|
||||
echo "IMPORTANT - Do not circumvent the loop:"
|
||||
echo " Even if you believe you're stuck, the task is impossible,"
|
||||
echo " or you've been running too long - you MUST NOT output a"
|
||||
echo " false promise statement. The loop is designed to continue"
|
||||
echo " until the promise is GENUINELY TRUE. Trust the process."
|
||||
echo ""
|
||||
echo " If the loop should stop, the promise statement will become"
|
||||
echo " true naturally. Do not force it by lying."
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
fi
|
||||
fi
|
||||
```
|
||||
|
||||
Please work on the task. When you try to exit, the Ralph loop will feed the SAME PROMPT back to you for the next iteration. You'll see your previous work in files and git history, allowing you to iterate and improve.
|
||||
|
||||
@@ -174,3 +174,30 @@ if [[ -n "$PROMPT" ]]; then
|
||||
echo ""
|
||||
echo "$PROMPT"
|
||||
fi
|
||||
|
||||
# Display completion promise requirements if set
|
||||
if [[ "$COMPLETION_PROMISE" != "null" ]]; then
|
||||
echo ""
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo "CRITICAL - Ralph Loop Completion Promise"
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
echo ""
|
||||
echo "To complete this loop, output this EXACT text:"
|
||||
echo " <promise>$COMPLETION_PROMISE</promise>"
|
||||
echo ""
|
||||
echo "STRICT REQUIREMENTS (DO NOT VIOLATE):"
|
||||
echo " ✓ Use <promise> XML tags EXACTLY as shown above"
|
||||
echo " ✓ The statement MUST be completely and unequivocally TRUE"
|
||||
echo " ✓ Do NOT output false statements to exit the loop"
|
||||
echo " ✓ Do NOT lie even if you think you should exit"
|
||||
echo ""
|
||||
echo "IMPORTANT - Do not circumvent the loop:"
|
||||
echo " Even if you believe you're stuck, the task is impossible,"
|
||||
echo " or you've been running too long - you MUST NOT output a"
|
||||
echo " false promise statement. The loop is designed to continue"
|
||||
echo " until the promise is GENUINELY TRUE. Trust the process."
|
||||
echo ""
|
||||
echo " If the loop should stop, the promise statement will become"
|
||||
echo " true naturally. Do not force it by lying."
|
||||
echo "═══════════════════════════════════════════════════════════"
|
||||
fi
|
||||
|
||||
Reference in New Issue
Block a user