diff --git a/src/session_analytics/patterns.py b/src/session_analytics/patterns.py index 505da57..f3e9384 100644 --- a/src/session_analytics/patterns.py +++ b/src/session_analytics/patterns.py @@ -551,13 +551,21 @@ def analyze_failures( def load_allowed_commands(settings_path: Path = DEFAULT_SETTINGS_PATH) -> set[str]: - """Load allowed commands from Claude Code settings.json. + """Load allowed base commands from Claude Code settings.json. + + Parses Bash permission patterns and extracts base commands: + - Bash(gh:*) → gh + - Bash(gh pr view:*) → gh + - Bash(git status:*) → git + + This means a command like `gh` won't be reported as a permission gap + if ANY pattern for `gh` exists (e.g., `Bash(gh pr view:*)`). Args: settings_path: Path to settings.json Returns: - Set of allowed command prefixes + Set of base commands that have any configured pattern """ if not settings_path.exists(): return set() @@ -566,16 +574,23 @@ def load_allowed_commands(settings_path: Path = DEFAULT_SETTINGS_PATH) -> set[st with open(settings_path) as f: settings = json.load(f) - allowed = set() + base_commands = set() permissions = settings.get("permissions", {}) - # Look for allow patterns with Bash(command:*) for pattern in permissions.get("allow", []): - if pattern.startswith("Bash(") and pattern.endswith(":*)"): - cmd = pattern[5:-3] # Extract command from "Bash(cmd:*)" - allowed.add(cmd) - - return allowed + if pattern.startswith("Bash(") and ":*)" in pattern: + # Extract full command from "Bash(command args:*)" + # Find the position of ":*)" to handle patterns correctly + start = 5 # len("Bash(") + end = pattern.find(":*)") + if end > start: + full_cmd = pattern[start:end] + # Extract base command (first word) + base_cmd = full_cmd.split()[0] if full_cmd else None + if base_cmd: + base_commands.add(base_cmd) + + return base_commands except (json.JSONDecodeError, OSError) as e: logger.warning(f"Could not load settings.json: {e}") return set() diff --git a/tests/test_patterns.py b/tests/test_patterns.py index 32c990c..1e29466 100644 --- a/tests/test_patterns.py +++ b/tests/test_patterns.py @@ -134,6 +134,56 @@ def test_permission_gaps_respects_allowed(self, pattern_storage): assert "make" not in pattern_keys assert "git" in pattern_keys + def test_load_allowed_commands_extracts_base_from_subcommands(self): + """Test that subcommand patterns extract the base command. + + Patterns like Bash(gh pr view:*) should extract 'gh' as the base command, + so that 'gh' isn't reported as a gap when subcommand patterns exist. + """ + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = Path(tmpdir) / "settings.json" + settings_path.write_text( + '{"permissions": {"allow": [' + '"Bash(gh pr view:*)", ' + '"Bash(gh issue list:*)", ' + '"Bash(git status:*)", ' + '"Bash(cargo build:*)"' + "]}}" + ) + allowed = load_allowed_commands(settings_path) + + # Should extract base commands, not full subcommands + assert "gh" in allowed + assert "git" in allowed + assert "cargo" in allowed + + # Should NOT contain full subcommand strings + assert "gh pr view" not in allowed + assert "git status" not in allowed + + def test_permission_gaps_filters_subcommand_patterns(self, pattern_storage): + """Test that gaps are filtered when subcommand patterns exist. + + If settings.json has Bash(gh pr view:*), then 'gh' should not be + reported as a permission gap even though it's used frequently. + """ + with tempfile.TemporaryDirectory() as tmpdir: + settings_path = Path(tmpdir) / "settings.json" + # Only git subcommand patterns configured, make is NOT configured + settings_path.write_text( + '{"permissions": {"allow": ["Bash(git status:*)", "Bash(git diff:*)"]}}' + ) + + patterns = compute_permission_gaps( + pattern_storage, days=7, threshold=1, settings_path=settings_path + ) + + pattern_keys = {p.pattern_key for p in patterns} + # git has subcommand patterns, should be filtered out + assert "git" not in pattern_keys + # make has no patterns, should still be a gap + assert "make" in pattern_keys + class TestComputeAllPatterns: """Tests for computing all patterns."""