From c1d1aa78f35f0a679b34d873147bda24ad897bdc Mon Sep 17 00:00:00 2001 From: Evan Senter Date: Fri, 2 Jan 2026 03:30:08 +0000 Subject: [PATCH] fix: Filter permission gaps against configured subcommand patterns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously, get_permission_gaps() would report commands like `gh` as gaps even when settings.json had patterns like `Bash(gh pr view:*)`. This happened because load_allowed_commands() extracted the full pattern content ("gh pr view") rather than the base command ("gh"). The fix extracts just the base command (first word) from each pattern, so any configured subcommand pattern covers the base command. Fixes #37 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- src/session_analytics/patterns.py | 33 ++++++++++++++------ tests/test_patterns.py | 50 +++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+), 9 deletions(-) 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."""