claudewheel v0.20.1 /claudewheel.guardrail
Edit
On this page

Canonical guardrail model: the four enforcement tiers, per-rule settings-coverage annotations, and the bash hook scripts generated from the rule set.

#claudewheel.guardrail

#claudewheel.guardrail

Canonical guardrail protocol model.

This module is the single source of truth for the fleet-wide command guardrails: which commands are hard-denied, which escalate to the user when a subagent tries them, which merely advise, and which prompt via settings. It carries everything later phases need:

- Phase 2 (bash generation) reads hook_patterns plus the tier semantics and advice text to emit the PreToolUse/PostToolUse hook scripts. - Phase 3 (settings) reads canonical_deny_rules() / canonical_ask_rules() to populate profile permissions and ALLOW_CONFLICTS to scrub dead or conflicting allow-array entries. - Phase 4 (health / patch) reads EXPECTED_HOOK_WIRINGS to verify each profile wires the four hook entries correctly.

The hook regex patterns are stored as PLAIN ERE text (Python raw strings, single-escaped). Translating them into a bash/grep template (with the extra layer of shell escaping) is Phase 2's job -- this module never emits bash.

#Tier

The four guardrail enforcement tiers.

  • HARD_DENY: denied for everyone (main agent and subagents) via a

PreToolUse hook, which is the AUTHORITATIVE enforcer. Any backing settings deny glob is best-effort defense-in-depth for the PLAIN command form only -- it does NOT reproduce the hook's full match surface (compound commands after a separator, wrapper invocations like sudo/env/xargs/find -exec, alternate remotes, etc.). A rule may even own no deny glob at all. See each rule's settings_coverage for how completely its deny glob(s) track the hook; never treat the deny array as a substitute for the hook.

  • ESCALATE: denied only when the caller is a subagent; the main agent

falls through silently so the settings ask rule prompts the user. As with HARD_DENY, the ask glob is best-effort for the plain form and the hook is authoritative (see settings_coverage).

  • ADVISE: PostToolUse advice only. The command runs; the agent is nudged

afterward via additionalContext. No settings entries.

  • ASK: settings ask rule only. No hook involvement at all.

#SettingsCoverage

How completely a rule's settings deny/ask glob(s) cover its hook surface.

Applies ONLY to the hook-backed-with-settings tiers HARD_DENY and ESCALATE. It is N/A (None on the rule) for ADVISE (hook only, no settings by design) and ASK (settings only, no hook).

  • FULL: the deny/ask glob(s) cover the rule's entire hook danger surface.
  • PARTIAL: the glob(s) cover only part of it (carry a coverage_reason

naming what the glob misses).

  • NONE: the rule owns no settings backstop of its own (carry a

coverage_reason explaining what, if anything, covers it instead).

#GuardrailRule

One guardrail rule.

Fields: - key: stable identifier, unique across all rules. - tier: which enforcement tier this rule belongs to. - hook_patterns: PLAIN ERE strings (raw, single-escaped) the hook matches against the command. Empty for pure-settings (ASK) rules. - deny_rules: settings permissions.deny array entries this rule contributes (HARD_DENY only, and only when it owns a deny rule). - ask_rules: settings permissions.ask array entries this rule contributes (ESCALATE and ASK). - main_advice: message shown to the main agent (HARD_DENY deny reason, ADVISE nudge). None for ESCALATE (hook is silent for main) and ASK. - subagent_advice: message shown to a subagent (HARD_DENY deny reason + suffix, ESCALATE escalation message, ADVISE nudge). None for ASK. - settings_coverage: how completely this rule's deny/ask glob(s) cover its hook surface (HARD_DENY/ESCALATE only; None for ADVISE/ASK). - coverage_reason: for PARTIAL/NONE coverage, a short note on what the settings glob misses or what covers the rule instead. Empty otherwise.

#_as_sentence

python
def _as_sentence(text: str) -> str

Ensure text ends with terminal sentence punctuation.

Strips trailing whitespace; if the result is non-empty and its last character is not one of ./!/?, appends a period. Only the final character matters -- internal punctuation (e.g. ;) is left untouched. Idempotent for text that already ends in terminal punctuation.

#_cmd

python
def _cmd(literal: str) -> str

SEP-anchor a command-matcher literal.

Prepends the shared separator anchor so the matcher only fires when the literal begins a shell command (start-of-string or after a ;/&/| separator), never mid-token (e.g. mygit add must not match git add).

#_wrapped_matcher

python
def _wrapped_matcher(cmd: str) -> str

SEP-anchored matcher for cmd plus indirect-invocation wrappers.

Emits three OR-joined branches so cmd is caught whether it is invoked directly or reached through a common wrapper:

- the SEP-anchored bare form (cmd at start / after a separator); - via sudo/env/xargs (allowing any leading -flag tokens); - via find's -exec/-execdir/-ok/-okdir actions.

cmd is an ERE fragment (e.g. rm or p?kill); each branch requires it to be followed by whitespace or end-of-string so rmdir/killall do not match.

#rules

python
def rules() -> tuple[GuardrailRule, ...]

Return every guardrail rule in canonical order.

#rules_by_tier

python
def rules_by_tier(tier: Tier) -> tuple[GuardrailRule, ...]

Return the rules belonging to tier, in canonical order.

#canonical_deny_rules

python
def canonical_deny_rules() -> list[str]

Return the ordered settings permissions.deny array.

Collected by walking RULES in order and concatenating each rule's deny_rules. The result is a frozen contract (pinned by tests).

#canonical_ask_rules

python
def canonical_ask_rules() -> list[str]

Return the ordered settings permissions.ask array.

Collected by walking RULES in order and concatenating each rule's ask_rules. The result is a frozen contract (pinned by tests).

#all_settings_rules

python
def all_settings_rules() -> list[str]

Return every settings rule (deny then ask), in canonical order.

#_bash_squote

python
def _bash_squote(s: str) -> str

Return s wrapped as a bash single-quoted literal (incl. the quotes).

Single quotes inside s are handled with the standard '\'' splice so the result is safe to drop verbatim into a bash script. ERE metacharacters (backslashes, pipes, dollar signs) are preserved literally because single quotes suppress all shell interpretation -- exactly what grep -qE needs.

#_bash_dquote_body

python
def _bash_dquote_body(s: str) -> str

Escape s for embedding INSIDE a bash double-quoted string.

Returns only the body (no surrounding quotes). Escapes the four characters bash still interprets inside double quotes: backslash, backtick, dollar, and double quote. jq (via --arg) handles JSON escaping downstream, so the message reaches Claude Code with its quotes intact.

#_match_condition

python
def _match_condition(rule: GuardrailRule) -> str

Build the shell test that matches rule's command patterns.

Each pattern becomes a printf ... | grep -qE '' pipeline; multiple patterns are OR-joined with || so the rule fires if ANY pattern hits.

#generate_blocker_script

python
def generate_blocker_script() -> str

Emit the full bash source for the hook-block-unsafe-commands hook.

Walks the canonical model twice -- HARD_DENY rules first, then ESCALATE rules -- each in RULES order (ordering is load-bearing: the more-specific rule must precede the general rule it overlaps). HARD_DENY denies everyone (subagent advice when agent_id is set, main advice otherwise); ESCALATE denies only subagents and lets the main agent fall through.

#generate_advise_script

python
def generate_advise_script() -> str

Emit the full bash source for the hook-advise-commands hook.

Walks the ADVISE-tier rules in RULES order. Each matching rule emits its advice as additionalContext via jq and exits. Non-matching commands produce empty stdout and exit 0.