Skip to content

feat(hooks): aelf-pre-issue-create guard — duplicate-detection before gh issue create (#941) - #945

Merged
github-actions[bot] merged 8 commits into
mainfrom
feat/issue-941-pre-issue-create-guard
Jun 4, 2026
Merged

github-actions[bot] merged 8 commits into
mainfrom
feat/issue-941-pre-issue-create-guard

Conversation

@robotrocketscience

@robotrocketscience robotrocketscience commented Jun 4, 2026

Copy link
Copy Markdown
Owner

What

Implements #941: a Claude Code PreToolUse:Bash guard that runs duplicate-detection before gh issue create is executed. Closes the gap between aelf-pr-open.sh (which gates gh pr create) and gh issue create (which had no analogous gate). The trap this closes is filing an issue describing behavior that already shipped under a different number — exactly the #929/#930#781 incident the issue body cites.

The guard is default-on (matching the 7 existing default-on hooks in the bundle), opt-out via --no-pre-issue-guard at aelf setup or AELFRICE_NO_PRE_ISSUE_GUARD=1 in env, and emergency-bypass via ALLOW_DUP_ISSUE=1. Non-gh issue create commands always PASS.

Mechanism

  1. Hook fires on every PreToolUse:Bash event. If tool_name != "Bash" or the command does not start with gh issue create, exit 0 immediately.
  2. Extract --title (and --body-file content if the path is a regular file outside ~/.claude/ — paths inside ~/.claude/ are silently refused).
  3. Tokenize: strip leading conventional-commit prefix, lowercase, split on non-alphanum, drop a small stop-word list.
  4. In parallel, run:
    • gh issue list --state all --search "<top-3 keywords>" --json number,title,state,stateReason,closedAt
    • git log --grep="<keyword|keyword|keyword>" --extended-regexp --oneline -20
  5. Score each candidate by Jaccard against the proposed title's tokens.
  6. If max score ≥ 0.5 → BLOCK (exit 2, message to stderr, top-5 candidates listed).
  7. Otherwise → PASS.

Per #605 PHILOSOPHY: pure deterministic surface — no embeddings, no LLM, no fuzzy heuristics beyond Jaccard. Per #606 hook convention: default-on, env opt-out, atomic install via aelf setup.

Smoke tests (live, against this branch)

# BLOCK on verbatim duplicate of recently-shipped #928
$ echo '{"tool_name":"Bash","tool_input":{"command":"gh issue create --title \"fix(setup): refuse worktree-pathed venv in hook resolvers\" --body test"}}' | uv run aelf-pre-issue-hook
aelf-pre-issue-guard: BLOCK — proposed title looks like a duplicate.
  Proposed: 'fix(setup): refuse worktree-pathed venv in hook resolvers'

  Top matches:
    (git-log)  [MERGED/shipped]  score=0.86  'fix(setup): refuse worktree-pathed venv in hook resolvers (#928)'
exit 2

# PASS on novel title
$ echo '{"tool_name":"Bash","tool_input":{"command":"gh issue create --title \"feat(badly-novel-zyzzyx): brand new untouched thingie\" --body none"}}' | uv run aelf-pre-issue-hook
exit 0

# Override bypass
$ echo '{"...same dup..."}' | ALLOW_DUP_ISSUE=1 uv run aelf-pre-issue-hook
exit 0

Files touched

  • src/aelfrice/pre_issue_create_hook.py (new, 445 LOC) — module: tokenizer, Jaccard scorer, run_guard() with injectable runners for testing, main() with real subprocess runners
  • src/aelfrice/setup.py (+95 LOC) — PRE_ISSUE_GUARD_* constants, install_pre_issue_guard_hook, uninstall_pre_issue_guard_hook, resolve_pre_issue_guard_command via the _resolve_script helper that landed in bug: aelf:setup pins worktree-local venv path into user settings.json, leaving stale hook entries on worktree removal #928's refactor
  • src/aelfrice/cli.py (+58 LOC) — wiring into _cmd_setup / _cmd_unsetup, --no-pre-issue-guard flag, _SETUP_FLAG_TO_HOOK_NAME entry
  • src/aelfrice/auto_install.pypre_issue_guard entry in _HOOK_INSTALLERS
  • src/aelfrice/data/hook_manifest.json — new entry (default_on: true, since: 3.4.0)
  • pyproject.tomlaelf-pre-issue-hook = "aelfrice.pre_issue_create_hook:main"
  • tests/test_pre_issue_create_hook.py (new, 52 tests) — tokenizer, scorer, run_guard with mocked runners, body-file path safety, env overrides
  • tests/test_aelf_setup_pre_issue_guard.py (new, 14 tests) — install / uninstall / CLI flag wiring
  • tests/test_cli_setup_opt_out_sync.py, tests/test_aelf_setup_search_tool_bash.py — adjusted existing assertions for the new hook count
  • docs/user/CONFIG.md, docs/user/INSTALL.md — one-paragraph descriptions

Acceptance ↔ implementation map

Out-of-scope (per issue body)

Test plan

  • uv run pytest tests/test_pre_issue_create_hook.py tests/test_aelf_setup_pre_issue_guard.py tests/test_cli_setup_opt_out_sync.py -x -q — 71 passed.
  • uv run pytest -x -q — 4950 passed, 68 skipped, 75 xfailed.
  • Live smoke run against the worktree (see above).
  • All 5 commits signed (%G? = G), conventional-commit prefixes, atomic.

Closes #941.

Summary by Sourcery

Add a default-on pre-issue guard hook that detects potentially duplicate GitHub issues before creation and wire it into setup, auto-install, and CLI configuration, with supporting tests and documentation.

New Features:

  • Introduce a PreToolUse:Bash hook (aelf-pre-issue-hook) that inspects gh issue create commands and blocks creation when the title closely matches existing issues or recent commits using deterministic Jaccard similarity.
  • Expose CLI and setup options to enable or disable the pre-issue guard, including per-run and global environment-based bypasses.

Enhancements:

  • Extend setup and auto-install wiring to install/uninstall the pre-issue guard hook alongside existing hooks, keeping opt-out state in sync.
  • Document configuration and installation behaviour for the pre-issue guard hook in the user docs.

Tests:

  • Add comprehensive unit tests for the pre-issue guard hook’s tokenization, scoring, guard behaviour, and env overrides.
  • Add tests covering setup/unsetup wiring and opt-out persistence for the pre-issue guard, and update existing hook setup tests to account for the new hook.

Summary by CodeRabbit

  • New Features

    • Added a pre-issue-create guard that prevents duplicate GitHub issues by validating proposed titles against existing issues and recent commits (enabled by default in v3.4.0+).
    • Support for disabling the guard via environment variables or the aelf setup --no-pre-issue-guard CLI option.
  • Documentation

    • Updated installation and configuration guides with details about the new guard and opt-out methods.

@robotrocketscience robotrocketscience added the author-garsecg PR coordination mutex label Jun 4, 2026
@coderabbitai

coderabbitai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@robotrocketscience, we couldn't start this review because you've reached your PR review rate limit.

More reviews will be available in 44 minutes and 18 seconds. Learn how PR review limits work.

Your organization has run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans include higher PR review limits than trial, open-source, and free plans. In all cases, reviews become available again over time. During sustained high-volume PR review activity, CodeRabbit may temporarily slow when the next review becomes available.

Please see our Fair Usage Limits Policy for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: be42130c-3b47-4a35-b437-70f6b7cff6cf

📥 Commits

Reviewing files that changed from the base of the PR and between 2b2a695 and f0f1efa.

📒 Files selected for processing (12)
  • docs/user/CONFIG.md
  • docs/user/INSTALL.md
  • pyproject.toml
  • src/aelfrice/auto_install.py
  • src/aelfrice/cli.py
  • src/aelfrice/data/hook_manifest.json
  • src/aelfrice/pre_issue_create_hook.py
  • src/aelfrice/setup.py
  • tests/test_aelf_setup_pre_issue_guard.py
  • tests/test_aelf_setup_search_tool_bash.py
  • tests/test_cli_setup_opt_out_sync.py
  • tests/test_pre_issue_create_hook.py
📝 Walkthrough

Walkthrough

This PR introduces a pre-issue-create guard hook that runs before gh issue create to check proposed issue titles against existing GitHub issues and recent commits using Jaccard token similarity, blocking creation when overlap exceeds 0.5 unless bypassed via environment variables or CLI flags.

Changes

Pre-issue-create Guard

Layer / File(s) Summary
Manifest, documentation, and script registration
src/aelfrice/data/hook_manifest.json, pyproject.toml, docs/user/CONFIG.md, docs/user/INSTALL.md
Hook manifest declares pre_issue_guard as default-on since v3.4.0 for PreToolUse:Bash duplicate detection. Project script registers aelf-pre-issue-hook console entry point. Documentation describes guard behavior, Jaccard threshold, tokenization rules, and opt-outs via ALLOW_DUP_ISSUE, AELFRICE_NO_PRE_ISSUE_GUARD, and aelf setup --no-pre-issue-guard.
Core hook implementation
src/aelfrice/pre_issue_create_hook.py
Hook parses stdin JSON envelope and applies guard logic only to gh issue create commands with --title. Tokenizes titles by stripping conventional-commit prefixes, lowercasing, splitting on non-alphanumerics, and removing stop-words/single-char tokens. Builds duplicate candidates from gh issue list (JSON-parsed) and git log --grep (synthesized), scores each via Jaccard similarity, and blocks (exit 2) when any candidate meets the 0.5 threshold, printing a ranked BLOCK message to stderr with bypass instructions; otherwise passes (exit 0). Body-file paths under ~/.claude/ are refused; failures fail-open as empty.
Setup, CLI, and auto-install wiring
src/aelfrice/setup.py, src/aelfrice/cli.py, src/aelfrice/auto_install.py
Setup functions install/uninstall the PreToolUse:Bash hook into settings.json using atomic write and existing dedupe helpers. CLI adds --pre-issue-guard/--no-pre-issue-guard flags to aelf setup and aelf unsetup commands, reflects the flag into auto-install opt-out sync (so --no-pre-issue-guard persists and can be rescinded). Auto-install dispatcher routes manifest pre_issue_guard installer key to setup functions.
Hook logic tests, setup integration tests, and opt-out tests
tests/test_pre_issue_create_hook.py, tests/test_aelf_setup_pre_issue_guard.py, tests/test_cli_setup_opt_out_sync.py, tests/test_aelf_setup_search_tool_bash.py
Unit tests validate tokenization (lowercase, prefix strip, scope removal, stop-word drop), Jaccard scoring, and command/body-file parsing with safety. Setup tests verify hook install/uninstall idempotence, coexistence with other PreToolUse entries, and CLI --no-pre-issue-guard flag behavior. Opt-out tests assert persistence and rescind on subsequent bare setup. Existing search-tool-bash tests updated to pass --no-pre-issue-guard to isolate test scope.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

  • robotrocketscience/aelfrice#627: Extends the shared hook-manifest auto-install system by adding a new pre_issue_guard manifest entry and wiring it through auto_install.py dispatcher and cli.py/setup.py opt-out synchronization, building directly on the hook infrastructure.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 34.31% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main feature: a pre-issue-create guard for duplicate detection before gh issue create, with reference to the related issue #941.
Description check ✅ Passed The description comprehensively covers all required sections: Summary (with clear 'why'), Linked issues (Closes #941), Type of change (feat), Verification checklist (all marked), Test plan (detailed with 71 new tests), and Notes for reviewer (out-of-scope items and live smoke tests documented).
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/issue-941-pre-issue-create-guard

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@sourcery-ai

sourcery-ai Bot commented Jun 4, 2026

Copy link
Copy Markdown

Reviewer's Guide

Adds a new default-on PreToolUse:Bash hook (aelf-pre-issue-hook) that runs deterministic duplicate detection before gh issue create, wires it into setup/auto-install/CLI, and ships a comprehensive test + docs update for configuration and installation behavior.

Sequence diagram for pre-issue-create duplicate-detection hook

sequenceDiagram
    actor Agent
    participant AelfPreIssueHook as aelf-pre-issue-hook
    participant gh as gh
    participant git as git

    Agent->>AelfPreIssueHook: main() stdin JSON (tool_name, tool_input.command)
    AelfPreIssueHook->>AelfPreIssueHook: run_guard(stdin_json)
    alt ALLOW_DUP_ISSUE or AELFRICE_NO_PRE_ISSUE_GUARD
        AelfPreIssueHook-->>Agent: exit 0
    else Non-Bash or not _is_gh_issue_create(command)
        AelfPreIssueHook-->>Agent: exit 0
    else Valid gh issue create with --title
        AelfPreIssueHook->>AelfPreIssueHook: tokenize_title(title)
        AelfPreIssueHook->>AelfPreIssueHook: _top_query_tokens(tokens)
        par Build candidates
            AelfPreIssueHook->>gh: _build_gh_candidates() via gh issue list
            gh-->>AelfPreIssueHook: JSON issues
            AelfPreIssueHook->>git: _build_git_candidates() via git log
            git-->>AelfPreIssueHook: commit lines
        end
        AelfPreIssueHook->>AelfPreIssueHook: _score_and_rank(tokens, candidates)
        alt max score >= BLOCK_THRESHOLD
            AelfPreIssueHook->>AelfPreIssueHook: _format_block_message()
            AelfPreIssueHook-->>Agent: stderr message, exit 2 (BLOCK)
        else below threshold or no candidates
            AelfPreIssueHook-->>Agent: exit 0 (PASS)
        end
    end
Loading

File-Level Changes

Change Details Files
Introduce deterministic duplicate-detection hook executed before gh issue create and expose it as a console script.
  • Implement tokenizer that strips conventional-commit prefixes, normalizes/lowercases, splits on non-alphanumeric characters, and removes stop words to build token sets from titles.
  • Add Jaccard-based scoring over title tokens with configurable threshold and ranking/formatting of top candidates.
  • Parse PreToolUse:Bash JSON input, detect gh issue create commands, extract title/body-file, and short-circuit for non-matching tools or commands.
  • Call gh issue list and git log via injectable runners to build candidate sets, score them, and block with exit 2 plus a formatted stderr report when any candidate exceeds the block threshold.
  • Respect ALLOW_DUP_ISSUE and AELFRICE_NO_PRE_ISSUE_GUARD env vars to bypass the guard, and safely ignore body files under ~/.claude/.
  • Provide a main() CLI entrypoint that reads the PreToolUse envelope from stdin, runs the guard, and exits with the returned code.
src/aelfrice/pre_issue_create_hook.py
pyproject.toml
Wire the pre-issue-guard hook into setup/unsetup, auto-install, and hook manifest so it is default-on but opt-out via CLI and env.
  • Define PRE_ISSUE_GUARD_* constants and add install_pre_issue_guard_hook/uninstall_pre_issue_guard_hook plus resolve_pre_issue_guard_command mirroring existing PreToolUse wiring.
  • Update aelf setup CLI to install the PreToolUse:Bash pre-issue-guard by default, add --[no-]pre-issue-guard flags for setup/unsetup, and register the hook name in the setup flag mapping.
  • Extend auto_install’s hook installer map with a pre_issue_guard entry using the new resolve/install helpers so auto-install respects persisted opt-outs.
  • Add a new hook_manifest.json entry marking pre-issue-guard as default_on since version 3.4.0.
  • Adjust existing search-tool-bash setup tests to explicitly disable the new hook to keep those tests scoped to their concern.
src/aelfrice/setup.py
src/aelfrice/cli.py
src/aelfrice/auto_install.py
src/aelfrice/data/hook_manifest.json
tests/test_aelf_setup_search_tool_bash.py
Add tests to cover the new guard’s behavior, setup wiring, and CLI opt-out persistence.
  • Create unit tests for tokenization, Jaccard scoring, candidate scoring, gh issue create detection, title/body-file extraction, body-file safety, and run_guard behavior under various scenarios (duplicates, novel titles, env overrides, runner failures).
  • Add tests for install_pre_issue_guard_hook/uninstall_pre_issue_guard_hook ensuring idempotency, coexistence with other PreToolUse hooks, and correct removal semantics by command and basename.
  • Add CLI tests verifying that aelf setup wires the pre-issue-guard hook by default and that --no-pre-issue-guard skips installation while persisting/removing opt-out flags in auto-install state.
  • Extend existing CLI opt-out sync tests to cover pre_issue_guard opt-out addition and rescind semantics.
tests/test_pre_issue_create_hook.py
tests/test_aelf_setup_pre_issue_guard.py
tests/test_cli_setup_opt_out_sync.py
Document configuration and installation of the pre-issue-guard hook for users.
  • Describe the pre-issue-create guard in CONFIG.md including behavior, default-on status, env overrides, and tokenization rules.
  • Update INSTALL.md’s hook table and opt-out examples to include pre-issue-guard and the corresponding --no-pre-issue-guard setup flag.
docs/user/CONFIG.md
docs/user/INSTALL.md

Assessment against linked issues

Issue Objective Addressed Explanation
#941 Implement a deterministic PreToolUse:Bash guard script for gh issue create that performs duplicate detection using GitHub issues and recent git commits, scores candidates, blocks likely duplicates by default, and allows override via ALLOW_DUP_ISSUE=1.
#941 Integrate the pre-issue guard into the aelf setup/auto-install system (default-on hook), with CLI and env-based opt-out controls, documentation, and tests that cover installation behavior and core acceptance cases (duplicate blocked, novel issue passes, override works, low false-positive rate).

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@robotrocketscience robotrocketscience added the attn:review Needs review (PR open, awaiting reviewer) label Jun 4, 2026
Comment thread src/aelfrice/pre_issue_create_hook.py Fixed
Comment thread src/aelfrice/pre_issue_create_hook.py Fixed
Comment thread tests/test_aelf_setup_pre_issue_guard.py Fixed
Comment thread tests/test_pre_issue_create_hook.py Fixed

@sourcery-ai sourcery-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 security issues, 2 other issues, and left some high level feedback:

Security issues:

  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)
  • Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'. (link)

General comments:

  • In pre_issue_create_hook._safe_read_body_file, _CLAUDE_DIR is computed once at import time using Path.home(), so any later HOME changes (common in tests or subprocesses) won’t be reflected; consider resolving the claude dir inside the function (or via a small helper) so it always reflects the current environment.
  • The body-file content is currently read in run_guard via _safe_read_body_file but never incorporated into scoring or messaging; if this is intentional, a short comment explaining why the body is ignored would help future readers, otherwise consider either wiring it into the duplicate heuristic or dropping the read to avoid dead work.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `pre_issue_create_hook._safe_read_body_file`, `_CLAUDE_DIR` is computed once at import time using `Path.home()`, so any later HOME changes (common in tests or subprocesses) won’t be reflected; consider resolving the claude dir inside the function (or via a small helper) so it always reflects the current environment.
- The body-file content is currently read in `run_guard` via `_safe_read_body_file` but never incorporated into scoring or messaging; if this is intentional, a short comment explaining why the body is ignored would help future readers, otherwise consider either wiring it into the duplicate heuristic or dropping the read to avoid dead work.

## Individual Comments

### Comment 1
<location path="src/aelfrice/pre_issue_create_hook.py" line_range="182-189" />
<code_context>
+    return ""
+
+
+def _safe_read_body_file(path_str: str) -> str:
+    """Read *path_str* if it is a regular file outside ``~/.claude/``.
+
+    Returns empty string on any failure or if the path is under ~/.claude/.
+    """
+    if not path_str:
+        return ""
+    p = Path(path_str)
+    try:
+        resolved = p.resolve()
</code_context>
<issue_to_address>
**suggestion (bug_risk):** Body-file path check won’t catch `~/.claude/...` because `Path.resolve()` doesn’t expand `~`.

Because `Path(path_str).resolve()` doesn’t expand `~`, a path like `~/.claude/foo` won’t be recognized as under `_CLAUDE_DIR`, so the protection only works if the caller has already expanded the user dir. To make this robust, call `Path(path_str).expanduser().resolve()` and apply `expanduser()` when computing `_CLAUDE_DIR` as well, so tilde paths are consistently blocked.

Suggested implementation:

```python
    if not path_str:
        return ""
    # Normalize user directories (e.g. "~") before resolving, so "~/.claude"
    # is consistently recognized as being under the Claude config directory.
    p = Path(path_str).expanduser()
    try:
        resolved = p.resolve()
    except (OSError, ValueError):
        return ""
    # Refuse paths that originate under ~/.claude/
    try:
        resolved.relative_to(_CLAUDE_DIR.expanduser().resolve())
        return ""  # inside ~/.claude/ — refuse
    except ValueError:
        pass

```

To fully implement the suggestion, also ensure `_CLAUDE_DIR` is created with `expanduser()`, e.g.:
`_CLAUDE_DIR = Path("~/.claude").expanduser()`. If `_CLAUDE_DIR` is currently defined without `expanduser()`, update that definition accordingly so both the directory constant and incoming paths are using the same tilde-expanded base.
</issue_to_address>

### Comment 2
<location path="src/aelfrice/pre_issue_create_hook.py" line_range="375-377" />
<code_context>
+        return 0
+
+    # --- Optional body read (title-only scoring is fine without body) --------
+    body_file = _extract_body_file(command)
+    _safe_read_body_file(body_file)  # read but not currently used in scoring
+
+    # --- Tokenize and build query -------------------------------------------
</code_context>
<issue_to_address>
**suggestion (performance):** Body-file read is currently unused, adding I/O cost without affecting the decision.

`_safe_read_body_file(body_file)` performs filesystem I/O on every guarded `gh issue create` but its result isn’t used for tokenization or scoring. Consider removing this read (or guarding it behind a flag) until body content is actually incorporated into the similarity logic.

```suggestion
    # --- Optional body path (reserved for future body-aware scoring) --------
    body_file = _extract_body_file(command)
    # NOTE: We intentionally avoid reading the body here to prevent
    # unnecessary filesystem I/O until body content participates in scoring.
```
</issue_to_address>

### Comment 3
<location path="src/aelfrice/pre_issue_create_hook.py" line_range="388-390" />
<code_context>
        result = subprocess.run(
            argv, capture_output=True, text=True, timeout=10,
        )
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

### Comment 4
<location path="src/aelfrice/pre_issue_create_hook.py" line_range="394-396" />
<code_context>
        result = subprocess.run(
            argv, capture_output=True, text=True, timeout=5,
        )
</code_context>
<issue_to_address>
**security (python.lang.security.audit.dangerous-subprocess-use-audit):** Detected subprocess function 'run' without a static string. If this data can be controlled by a malicious actor, it may be an instance of command injection. Audit the use of this call to ensure it is not controllable by an external resource. You may consider using 'shlex.escape()'.

*Source: opengrep*
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment thread src/aelfrice/pre_issue_create_hook.py Outdated
Comment thread src/aelfrice/pre_issue_create_hook.py
Comment thread src/aelfrice/pre_issue_create_hook.py Outdated
Comment thread src/aelfrice/pre_issue_create_hook.py Outdated
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

PR-size soft cap

This PR is over the advisory size threshold:

  • 1359 changed lines (limit: 200)
  • 12 changed files (limit: 3)

Bigger PRs collide with more open work, which under the parallel-session workflow tends to produce repeated attn:merge-conflict cycles (see #602). When practical, split into smaller PRs that each touch a focused surface.

This is advisory only — nothing is blocked. If the size is intentional (large refactor, module removal, generated code), apply the size:override label and this comment will be removed on the next push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Setr:2026-06-04T19:53:18Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Kulili:2026-06-04T19:53:26Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Kulili:2026-06-04T19:53:30Z]

@robotrocketscience

Copy link
Copy Markdown
Owner Author

Substantively approve, two small follow-ups to consider before merge (or as a follow-up PR — either works).

Verified

  • Discretion grep on diff: clean. Sister-session terms, harness names, and tier labels all absent. Module docstring uses "host harness" framing consistently.
  • All CI green except Sourcery (covered below).
  • 52 unit tests across 8 test classes — tokenizer/Jaccard/score/command-parsing/run-guard/body-file all covered including the _safe_read_body_file claude_dir_refused case.
  • Manifest + pyproject wiring is consistent with the 7 existing default-on hooks (since, installer, basename shape match).
  • _is_gh_issue_create tolerates KEY=VAL gh ... env-var prefixes.
  • _extract_title / _extract_body_file cover --flag val, -x val, and --flag=val forms.
  • Injectable gh_runner / git_runner make run_guard deterministic in tests.

Sourcery's two subprocess findings are false positives

Both "subprocess.run without static string" hits operate on token-list arguments built from sorted query tokens (_top_query_tokens returns sorted(tokens)[:3]). No shell metacharacter path; no command injection surface. Safe to dismiss.

Two real findings worth a small follow-up

(1) _safe_read_body_file doesn't expanduser() — defense-in-depth gap

Path("~/.claude/foo").resolve() does NOT expand ~; it treats ~ as a literal path component. In practice the protection still holds because the resulting cwd/~/.claude/foo path resolves to a non-existent file and the read returns empty — but the contract isn't expressed in code. The host harness passes the model-authored command string pre-shell-expansion, so a model writing --body-file ~/.claude/secrets.md lands as a literal ~/... token here.

Recommended one-line fix:

p = Path(path_str).expanduser()
# ...
resolved.relative_to(_CLAUDE_DIR.expanduser().resolve())

Plus a one-line test asserting _safe_read_body_file("~/.claude/foo.md") returns "" even when that file actually exists at the expanded path. Matches the discretion-rule the hook is structurally enforcing.

(2) Dead body-file read in run_guard

L376-377:

body_file = _extract_body_file(command)
_safe_read_body_file(body_file)  # read but not currently used in scoring

The body content is read, then discarded. Two clean options:

  • Drop the read. Saves an I/O hit per invocation and removes the dead-code surface; if body-incorporation lands later it's a deliberate addition rather than a half-wiring.
  • Wire body tokens into scoring. Tokenize the body, union with title tokens, score against candidate titles. Catches duplicates where the title is generic ("fix bug") but the body is specific. Likely good for recall on real duplicates that share little title overlap.

Either is fine; surfacing because the comment "currently not used" tends to rot into permanently-dead I/O. If keeping for forward-compat, prefer a _: str = _safe_read_body_file(body_file) # reserved for future body-token scoring so the intent reads as deliberate.

Minor — since: "3.4.0" may need bumping

hook_manifest.json says "since": "3.4.0". v3.4.0 was tagged 2026-05-26 (PR #924). This PR will ship in the next release. If that's v3.5.0, the manifest entry should say so; if it's a v3.4.1 patch, current value stays.

Verdict

None of the above are blockers. The hook design is sound, the discretion contract is honored, tests are thorough. Adding ready-to-merge. If the reviewer who picks this up wants the two follow-ups in-PR before merge, that's a defensible call too — they're small enough to fold in.

@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Setr:2026-06-04T19:56:07Z]

@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

merge-train: blocked

3 review thread(s) are unresolved on these files: src/aelfrice/pre_issue_create_hook.py, tests/test_aelf_setup_pre_issue_guard.py, tests/test_pre_issue_create_hook.py. Resolve them on the PR (click 'Resolve conversation' on each) and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@robotrocketscience

Copy link
Copy Markdown
Owner Author

[claim:review:Kulili:2026-06-04T21:28:01Z]

@robotrocketscience
robotrocketscience force-pushed the feat/issue-941-pre-issue-create-guard branch from c4d9530 to 2b2a695 Compare June 4, 2026 21:30
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base 9eee7b53ebfdadca1704cc0f50fd47f1a8f1125a, current main be946fbe93f7b47d697a796ae18e4947df72544a). Rebase locally (git rebase github/main), force-push, and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@docs/user/CONFIG.md`:
- Line 509: Update the wording in the docs so the threshold describes the actual
implemented comparison (>= 0.5) rather than “exceeds 0.5”; change the phrase at
the line that currently reads “exceeds 0.5” to something like “is at least 0.5
(>= 0.5)” to match the behavior enforced by pre_issue_create_hook which guards
on >= 0.5.

In `@docs/user/INSTALL.md`:
- Around line 148-149: Update the documentation to remove or amend the blanket
statement that “All hooks are non-blocking… exit 0” to reflect that the newly
introduced pre-issue-guard (PreToolUse:Bash) is a blocking hook; explicitly
mention that pre-issue-guard (v3.4.0+) will block `gh issue create` when titles
overlap an existing issue/commit above 0.5 Jaccard and therefore can exit
non-zero to prevent the action, and adjust any examples or the sentence at the
end of the hooks list to note that most hooks are non-blocking except for the
blocking pre-issue-guard.

In `@src/aelfrice/pre_issue_create_hook.py`:
- Around line 240-244: The code currently returns any parsed list from
json.loads(raw) as-is (parsed) but later code assumes each candidate supports
.get(...), so filter and validate the JSON row shape: replace the raw return of
parsed with a filtered list like [item for item in parsed if isinstance(item,
dict)] (or coerce invalid rows into dicts or drop them) and optionally log/count
dropped items; update the branch handling json.loads(raw) to return only dict
entries so downstream uses of candidate.get(...) are safe.

In `@tests/test_pre_issue_create_hook.py`:
- Line 212: The test function test_real_file has the tmp_path parameter
incorrectly typed as pytest.TempdirFactory; change the parameter annotation to
Path (from pathlib) and add an import for Path if missing so the signature
becomes def test_real_file(self, tmp_path: Path) -> None and the test uses the
correct pathlib.Path type provided by the tmp_path fixture.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5d765742-5e97-4d23-b1cc-8ccf2eccfef7

📥 Commits

Reviewing files that changed from the base of the PR and between 4067ecc and 2b2a695.

📒 Files selected for processing (12)
  • docs/user/CONFIG.md
  • docs/user/INSTALL.md
  • pyproject.toml
  • src/aelfrice/auto_install.py
  • src/aelfrice/cli.py
  • src/aelfrice/data/hook_manifest.json
  • src/aelfrice/pre_issue_create_hook.py
  • src/aelfrice/setup.py
  • tests/test_aelf_setup_pre_issue_guard.py
  • tests/test_aelf_setup_search_tool_bash.py
  • tests/test_cli_setup_opt_out_sync.py
  • tests/test_pre_issue_create_hook.py

Comment thread docs/user/CONFIG.md
Comment thread docs/user/INSTALL.md
Comment thread src/aelfrice/pre_issue_create_hook.py
Comment thread tests/test_pre_issue_create_hook.py
@robotrocketscience
robotrocketscience force-pushed the feat/issue-941-pre-issue-create-guard branch from 8bf85f1 to 5803c69 Compare June 4, 2026 21:38
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

merge-train: blocked

3 review thread(s) are unresolved on these files: docs/user/CONFIG.md, src/aelfrice/pre_issue_create_hook.py, tests/test_pre_issue_create_hook.py. Resolve them on the PR (click 'Resolve conversation' on each) and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

merge-train: blocked

branch is not fast-forward on main (branch base be946fbe93f7b47d697a796ae18e4947df72544a, current main 6d24304bfcdbe1d46f349598edf684b22d7e1691). Rebase locally (git rebase github/main), force-push, and re-add the label.

The ready-to-merge label has been removed. Address the issue above and re-add the label when you're ready for another attempt.

@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
Tokenizes issue titles (strips conventional-commit prefix, lowercases,
drops stop-words), scores candidates via Jaccard similarity, and blocks
gh issue create calls whose title overlaps an existing issue or merged
commit above the 0.5 threshold.  ALLOW_DUP_ISSUE=1 and
AELFRICE_NO_PRE_ISSUE_GUARD=1 bypass the guard.  Runners are injectable
for unit testing.
Unit tests for tokenize_title (prefix stripping, stop-word removal),
jaccard, score_candidate, and run_guard with mocked gh/git runners.
Covers PASS/BLOCK paths, env-var overrides, false-positive guard,
and the ~/.claude/ body-file refusal path.
Registers aelf-pre-issue-hook = aelfrice.pre_issue_create_hook:main
in pyproject.toml so the guard ships as an installable console script
alongside the other hook entry points.
Adds resolve_pre_issue_guard_command, install_pre_issue_guard_hook,
uninstall_pre_issue_guard_hook to setup.py; wires them into _cmd_setup
and _cmd_unsetup with --pre-issue-guard / --no-pre-issue-guard
BooleanOptionalAction flags; registers the hook in auto_install and
hook_manifest.json (default_on, since 3.4.0).

Tests: test_aelf_setup_pre_issue_guard.py (unit + CLI) and two new
opt-out-sync cases; existing search-tool-bash CLI tests scoped to
--no-pre-issue-guard to remain single-hook assertions.
Adds the pre-issue-guard row to the hook bundle table in INSTALL.md,
the --no-pre-issue-guard opt-out line to the code block, and a new
CONFIG.md section covering the env-var overrides and how to opt out
per-call or globally.
- Path(path_str).expanduser() so ~/.claude/ guard catches tilde-prefixed
  paths (was previously bypassed because Path.resolve() doesn't expand ~).
- Document the three intentional except:pass sites with one-line comments
  explaining the fail-open intent (CodeQL: py/empty-except).
- Drop unused 'json' import in test_aelf_setup_pre_issue_guard.py.
- Drop unused 'BLOCK_THRESHOLD' import in test_pre_issue_create_hook.py.
Sourcery review flags subprocess.run() with dynamic argv as a potential
shell-injection vector. The list-form (no shell=True) is structurally
safe — argv is built from constants + tokens we tokenized ourselves —
but the static-analysis flag blocks merge. Annotate the two call sites
with # noqa: S603 and a one-line rationale.
@robotrocketscience
robotrocketscience force-pushed the feat/issue-941-pre-issue-create-guard branch from 5803c69 to f0f1efa Compare June 4, 2026 21:45
@robotrocketscience robotrocketscience added the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@github-actions github-actions Bot removed the ready-to-merge Trigger merge-train: FF main to this PR's head label Jun 4, 2026
@github-actions
github-actions Bot merged commit f0f1efa into main Jun 4, 2026
29 checks passed
@github-actions

github-actions Bot commented Jun 4, 2026

Copy link
Copy Markdown

merge-train: merged f0f1efamain via FF push.

@robotrocketscience

Copy link
Copy Markdown
Owner Author

[release:review:Kulili:2026-06-04T21:48:55Z]

@robotrocketscience
robotrocketscience deleted the feat/issue-941-pre-issue-create-guard branch June 10, 2026 22:20
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

attn:review Needs review (PR open, awaiting reviewer) author-garsecg PR coordination mutex

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(hooks): aelf-pre-issue-create guard — duplicate-detection before gh issue create

2 participants