feat(gates): opt-in to re-enable BASH-PARSER-COMPOUND deny#288
Conversation
The PreToolUse hook collapsed T2 (TIER-ASK) findings into the same exit-2 deny path as T3 (TIER-DENY), so commands like git push and gh pr merge got silently blocked instead of surfacing Claude Code's yellow permission prompt. check_tool_input now also returns a verdict field with values allow, ask, or deny. This is a non-breaking superset: the existing safe boolean is unchanged for the 7 callers that read it. _gate_bash, _gate_spawn, and _gate_state_sanitize short-circuit on verdict == ask to emit hookSpecificOutput.permissionDecision = ask and exit 0, letting the harness render its native permission prompt. Mixed TIER-ASK plus non-ask findings still resolve to deny (deny-wins invariant). 12 new tests cover pure-T2, pure-T3, mixed, and safe input cases plus the deny-wins invariant.
The 0.63.2 compound-allow change deliberately widened the bash gate's allowlist so the L4 parser stopped emitting a CRITICAL deny on every pipe, double-ampersand, double-pipe, semicolon, and control-flow construct. Operators with stricter threat models can now restore the pre-0.63.2 policy via either of two opt-ins: - Environment variable SPELLBOOK_BASH_DENY_COMPOUND=1 (truthy values: 1, true, yes, case-insensitive, whitespace-tolerant). - Passing security_mode="paranoid" to spellbook.gates.bash_parser.parse_and_check (call-site control). Either path re-emits BASH-PARSER-COMPOUND for list/pipeline nodes AND for if/for/while/until/case/function control-flow constructs. With neither opt-in active, default behavior is unchanged from 0.63.2. Implementation: - New _compound_deny_enabled(security_mode) helper centralizes the truthy-env-var parse and the paranoid-mode check. - security_mode is threaded through private _walk, _classify_node, and _classify_compound. Public parse_and_check signature unchanged. - function added to the control-flow kind set in _classify_node for symmetry with the other control-flow kinds. - CHANGELOG Unreleased section documents the opt-in; the 0.63.2 entry gets a back-reference framing the prior change as a deliberate policy relaxation. Tests: - 7 new tests in TestCompoundDenyOptIn cover env-var truthy values, case/whitespace tolerance, paranoid mode, control-flow node coverage, and default-off behavior. - New autouse fixture _scrub_compound_deny_env prevents env-var pollution from breaking unrelated tests. - Full suite: 132 passed (with and without the env var set in the parent process).
|
❌ Momus review failed — phase phase2 failed: provider error: 402 This request requires more credits, or fewer max_tokens. You requested up to 8192 tokens, but can only afford 5957. To increase, visit https://openrouter.ai/settings/keys and create a key with a h... |
There was a problem hiding this comment.
Code Review
This pull request introduces an opt-in mechanism to re-enable BASH-PARSER-COMPOUND deny findings for Bash control-flow constructs and pipelines, controllable via the SPELLBOOK_BASH_DENY_COMPOUND environment variable or a security_mode="paranoid" parameter. Feedback correctly identifies that the current default security_mode="paranoid" in parse_and_check inadvertently enables these denials by default, contradicting the PR's stated intent. Additionally, improvements were suggested for the operator extraction logic in _classify_compound to correctly handle pipe nodes and deduplicate operators in finding messages. Finally, the PR is missing a required version bump in the .version file, which is a high-severity violation of the repository style guide.
| findings: list[dict] = [] | ||
| for tree in trees: | ||
| findings.extend(_walk(tree, command)) | ||
| findings.extend(_walk(tree, command, security_mode)) |
There was a problem hiding this comment.
The parse_and_check function (at line 362) defaults security_mode to "paranoid". Since _compound_deny_enabled now returns True for "paranoid", this change effectively enables compound-command denial by default for direct callers of this function, which contradicts the PR description's claim that default behavior is unchanged. Additionally, the docstring at line 367 is now inaccurate as it states the modes are treated equally. Consider changing the default to "standard" and updating the docstring.
| operators = [ | ||
| getattr(p, "op", None) | ||
| for p in parts | ||
| if getattr(p, "kind", None) == "operator" | ||
| ] | ||
| op_text = ", ".join(op for op in operators if op) or "|" |
There was a problem hiding this comment.
The current implementation only extracts operators from operator nodes, which means pipeline nodes (using pipe nodes in bashlex) always fall back to the default |. Additionally, the finding message can contain redundant operators if the same one is used multiple times (e.g., Compound command (&&, &&) is not allowed). Deduplicating while preserving order and explicitly handling pipe nodes would be more robust.
parts = getattr(node, "parts", ()) or ()
operators = [
getattr(p, "op", "|") if getattr(p, "kind", None) == "pipe" else getattr(p, "op", None)
for p in parts
if getattr(p, "kind", None) in {"operator", "pipe"}
]
op_text = ", ".join(dict.fromkeys(op for op in operators if op)) or "|"Remove redundant str() wraps on rule_id values; rule_ids are always string literals constructed inside the gate code. Aligns with the existing pattern elsewhere in the codebase (e.g. tests use f["rule_id"].startswith(...) directly). - hooks/spellbook_hook.py:363 (_emit_ask_and_exit TIER-ASK filter) - spellbook/gates/check.py:250 (_compute_verdict TIER-ASK check) Skipped the suggestion to remove the `if non_low` guard in _compute_verdict: the guard preserves correct behavior when the function is called directly with safe=False but an empty findings list (e.g. defensive callers, future tests). The redundancy gemini identified is real but the defensive check is cheap and intentional.
…string - tests/unit/test_stint_hooks.py: read proc.stderr instead of proc.stdout for gate-error JSON (matches the stderr routing introduced in 324cab5 but missed by that commit) - hooks/spellbook_hook.py: docstring at _gate_bash referenced stdout where the implementation already routes to stderr
…llow default The tests imported by the fix-t2-ask-prompt merge assumed BASH-PARSER-COMPOUND fires as CRITICAL on `git push ... && ...` style compounds — true before 0.63.2, but compound deny is now opt-in. The tests' real intent is to exercise the verdict computation with a CRITICAL finding alongside a T2 ask finding, regardless of which layer produces the CRITICAL. The merge also added a `verdict` field to `check_tool_input()` results without updating strict-equality assertions in `test_hooks_windows.py`, breaking 5 tests there. Updated: - test_check.py::test_critical_bashlex_finding_is_deny: opt into `SPELLBOOK_BASH_DENY_COMPOUND=1` via monkeypatch so the bashlex layer can produce a CRITICAL alongside the T2 TIER-ASK. - test_hooks.py::test_pure_t3_still_exits_2: switch error-JSON read from `proc.stdout` to `proc.stderr` (post-324cab5b stderr routing). - test_hooks.py::test_mixed_t2_and_critical_exits_2: same stderr switch, plus opt into compound deny via the `_run_bash_gate` `env_overrides` parameter. - test_hooks_windows.py: extend 5 strict-equality assertions (test_safe_bash_command_is_allowed, test_safe_spawn_prompt_is_allowed, test_injection_prompt_is_blocked, test_safe_workflow_state_is_allowed, test_injected_workflow_state_is_blocked) to include the new `verdict` field with the expected `allow` / `deny` value.
# Conflicts: # CHANGELOG.md # tests/unit/test_stint_hooks.py
|
@gemini-code-assist @axiomantic-momus please re-review. New commits on this branch since the last pass (push
/ai-review |
There was a problem hiding this comment.
Code Review
This pull request introduces an opt-in mechanism to re-enable BASH-PARSER-COMPOUND security findings using either the SPELLBOOK_BASH_DENY_COMPOUND environment variable or a "paranoid" security mode. It also implements a new "ask" verdict across the security gates, enabling interactive permission prompts for specific actions like git push. Feedback suggests refactoring the repetitive result-handling logic within the hook gate functions into a shared helper to improve maintainability and reduce redundancy.
| if result.get("verdict") == "ask": | ||
| _emit_ask_and_exit(result["findings"]) |
There was a problem hiding this comment.
This new logic, combined with the following if not result["safe"] block, creates a pattern that is duplicated across _gate_bash, _gate_spawn, and _gate_state_sanitize. To improve maintainability and avoid redundancy, consider extracting the entire logical block into a shared helper function.
For example, you could create a helper like this:
def _handle_check_result(result: dict) -> None:
"""Process the result from check_tool_input, exiting if necessary."""
if result.get("verdict") == "ask":
_emit_ask_and_exit(result["findings"])
if not result["safe"]:
reasons = "; ".join(f["message"] for f in result["findings"])
print(json.dumps({"error": f"Security check failed: {reasons}"}), file=sys.stderr)
sys.exit(2)Then, _gate_bash and the other gate functions can be simplified to call this helper:
def _gate_bash(data: dict) -> None:
# ... (try/except and tool_input check)
result = check_tool_input("Bash", tool_input)
_handle_check_result(result)- HIGH: change parse_and_check default security_mode from "paranoid" to "standard" so direct callers get the same default behavior as the public check_tool_input API (compound allowed by default). Update docstring to reflect the new mode semantics. - MEDIUM: fix _classify_compound operator extraction to also inspect pipeline `pipe` nodes (previously pipelines always fell back to the literal "|" default) and deduplicate duplicate operators in the finding message. - MEDIUM: extract _handle_check_result helper from _gate_bash / _gate_spawn / _gate_state_sanitize to reduce duplication of the ask/deny result handling.
|
@gemini-code-assist @axiomantic-momus please re-review. Addressed all three findings from the previous gemini pass (commit
Local suite: 915 passing across /ai-review |
There was a problem hiding this comment.
Code Review
This pull request introduces a 'verdict' system for security gates, allowing the harness to distinguish between allowing, denying, or asking for user permission (TIER-ASK). It refactors the bash parser to support an opt-in for compound command restrictions and updates the hook logic to handle these new states. A review comment suggests improving error message clarity by filtering out low-severity findings. Additionally, the PR is missing the mandatory version bump in the .version file as specified in the repository style guide.
| if result.get("verdict") == "ask": | ||
| _emit_ask_and_exit(result["findings"]) | ||
| if not result["safe"]: | ||
| reasons = "; ".join(f["message"] for f in result["findings"]) |
There was a problem hiding this comment.
The reasons string is constructed from the messages of all findings in the result, which can include LOW severity findings. Since a block is triggered only by non-LOW severity findings, including messages from LOW severity findings could add noise to the error message.
To make the error message more precise, consider filtering for non-LOW severity findings when building the reasons string.
| reasons = "; ".join(f["message"] for f in result["findings"]) | |
| reasons = "; ".join(f["message"] for f in result["findings"] if f.get("severity") != "LOW") |
Three real failure modes hit this session prompted explicit guidance: T2 ask gate that subagents cannot relay to the operator, bashlex parse errors on inline heredocs, and high-entropy detection on command-line payloads. Documents the layered pipeline, common block messages with mapped responses, the subagent escape hatch (surface to orchestrator, do not retry), and the heredoc-to-file workaround pattern. The content lands in AGENTS.spellbook.md so it propagates into every user's global CLAUDE.md, making the guidance authoritative for all spellbook-augmented harnesses (Claude Code, OpenCode, Codex, Gemini CLI, ForgeCode).
The _handle_check_result helper built the reasons string from all findings, but a block is only triggered by non-LOW severities. LOW findings (e.g., advisory rule_ids that should never block) added noise to the user-facing error message. Filter them out. Addresses gemini-code-assist review on PR #288.
|
@gemini-code-assist @axiomantic-momus please re-review. Addressed the MEDIUM finding from your last pass on
Note on the version-bump question raised in the last review: deferring that decision to the merge step — happy to bump if you (or the operator) consider it a hard block on this PR. Local suite: 781 passing across /ai-review |
|
Thanks for the update, @elijahr. I've reviewed the changes in commit |
What does this PR do?
Adds an opt-in to re-enable
BASH-PARSER-COMPOUNDdeny findings, restoring the pre-0.63.2 policy for operators with stricter threat models. Default behavior is unchanged: with neither opt-in active, the bash gate still allows benign compound commands likels | headandwc -l file && ls.Two opt-in paths:
SPELLBOOK_BASH_DENY_COMPOUND=1(truthy values:1,true,yes, case-insensitive, whitespace-tolerant).security_mode="paranoid"tospellbook.gates.bash_parser.parse_and_check(call-site control).Either path re-emits
BASH-PARSER-COMPOUNDforlist/pipelinenodes AND forif/for/while/until/case/functioncontrol-flow constructs.Implementation notes
_compound_deny_enabled(security_mode)helper centralizes the truthy-env-var parse and the paranoid-mode check.security_modeis threaded through private_walk,_classify_node, and_classify_compound. Publicparse_and_checksignature is unchanged.functionadded to the control-flow kind set in_classify_nodefor symmetry with the other control-flow kinds.[Unreleased]section documents the opt-in; the 0.63.2 entry gets a back-reference framing the prior change as a deliberate policy relaxation.[Unreleased]section will be promoted in the nextchore(release):commit.Tests
TestCompoundDenyOptIncover env-var truthy values, case/whitespace tolerance, paranoid mode, control-flow node coverage, and default-off behavior._scrub_compound_deny_envprevents env-var pollution from breaking unrelated tests.tests/gates/test_bash_parser.py(verified both with and withoutSPELLBOOK_BASH_DENY_COMPOUND=1set in the parent process).Related issue
None.
Checklist