Skip to content

Why runok?

Claude Code provides built-in command permissions through settings.json. For simple allowlists, these work fine. But as your workflow grows, you encounter limitations that cause unexpected confirmation prompts, silent failures, and security gaps.

This page describes specific problems and shows how runok addresses each one.

Claude frequently adds a comment before a command:

# check recent commits
git log --oneline -5

Even though Bash(git log *) is in your allow list, the comment introduces a newline. Claude Code treats the entire string as a single command that no longer matches the glob pattern. You see a confirmation prompt with a message like “Command contains newlines that could separate multiple commands” — but the reason is not obvious.

How runok handles this:

runok uses tree-sitter-bash to parse the command into an AST. Comments are stripped during parsing. The actual command git log --oneline -5 is extracted and evaluated against your rules.

runok.yml
rules:
- allow: 'git log *'

This rule matches regardless of leading comments.

Claude Code uses text-based heuristics to analyze commands. This works for simple cases, but compound commands and certain argument patterns trigger unexpected confirmation prompts:

⏺ Bash(git log --oneline -5 && echo "---" && git status)
Command contains quoted characters in flag names

Every sub-command here is safe, but the text-based check flags the entire command. Users see a confirmation prompt with no clear way to prevent it through configuration.

How runok handles this:

runok uses tree-sitter-bash to parse commands into a full AST. Compound commands (&&, ||, ;, |) are decomposed into individual sub-commands, and each one is evaluated independently against your rules. See Compound Commands for details.

runok.yml
rules:
- allow: 'git log *'
- allow: 'echo *'
- allow: 'git status'
# "git log --oneline -5 && echo '---' && git status"
# -> each sub-command is allowed -> final result: allow

In Claude Code’s settings.json, the deny list is an array of patterns:

settings.json
{
"permissions": {
"deny": ["Bash(git push --force*)"]
}
}

When Claude tries git push --force, the command is blocked — but neither the user nor the agent knows why. The agent cannot learn from the denial or suggest an alternative.

How runok handles this:

runok supports message and fix_suggestion fields on deny rules:

runok.yml
rules:
- deny: 'git push -f|--force *'
message: 'Force push rewrites history and is not allowed on this project.'
fix_suggestion: 'git push --force-with-lease'

The message is returned to the AI agent, which reads it and retries with the suggested command. See Denial Feedback for details.

Claude sometimes uses global flags before the subcommand:

Terminal window
git -C /path/to/repo commit -m "fix"

Claude Code’s glob matching for Bash(git commit *) does not match this because -C /path/to/repo appears between git and commit. While * can appear at any position in a Claude Code pattern (e.g., Bash(git * main)), there is no way to express “match an optional flag with its argument in any position.”

How runok handles this:

runok’s pattern syntax supports optional groups that match zero or one occurrence of a flag in any position:

runok.yml
rules:
- allow: 'git [-C *] commit *'

All flags are matched order-independently by default, so flag position within the command does not matter.

No recursive parsing of subshells and wrappers

Section titled “No recursive parsing of subshells and wrappers”

Claude may generate commands like:

Terminal window
sudo bash -c "rm -rf /tmp/build"

Claude Code evaluates the entire string as one command. It cannot look inside sudo, bash -c, $() subshells, or backticks to see what actually runs.

How runok handles this:

runok defines wrapper patterns in definitions.wrappers and recursively extracts and evaluates the inner command:

runok.yml
definitions:
wrappers:
- 'sudo <cmd>'
- 'bash -c <cmd>'
rules:
- allow: 'rm -rf /tmp/build'
- deny: 'rm -rf /'

For sudo bash -c "rm -rf /", runok unwraps through sudo, then bash -c, reaches rm -rf /, and denies it. See Wrapper Command Recursion for details.

Claude Code’s OS-level sandbox applies the same restrictions to all commands. There is no way to say “run Python in a restricted environment but let git access the network freely.”

How runok handles this:

runok attaches sandbox presets to individual rules:

runok.yml
definitions:
sandbox:
restricted:
fs:
writable: ['.']
deny: ['.git', '.env*']
network:
allow: false
rules:
- allow: 'python3 *'
sandbox: restricted
- allow: 'git *'
# no sandbox -- full access

Python runs with filesystem and network restrictions. Git runs unrestricted. See Sandbox for details.

Claude Code uses settings.json for configuration. JSON does not support comments. As your permission rules grow, you cannot annotate why a rule exists, who requested it, or when it was added.

How runok handles this:

runok uses YAML, which supports comments natively:

runok.yml
rules:
# read-only git commands are always safe
- allow: 'git status'
- allow: 'git diff *'
- allow: 'git log *'
# allow push, but not force push -- rewrites shared history
- deny: 'git push -f|--force *'
message: 'Use --force-with-lease instead.'
- ask: 'git push *'

Claude Code’s Bash(pattern*) glob syntax supports * wildcards at any position, but it cannot express:

  • Flag alternation: “match -f or --force
  • Optional arguments: “match with or without --verbose
  • Negation: “match any verb except delete
  • Argument-order-independent matching

How runok handles this:

runok’s pattern syntax covers all of these:

runok.yml
rules:
# Flag alternation
- deny: 'git push -f|--force *'
# Optional group
- allow: 'curl [-o|--output *] -X|--request GET *'
# Deny kubectl verbs except describe and get
- deny: 'kubectl !describe|get *'
# All flags are matched order-independently by default
- allow: 'git push --force-with-lease *'
# Matches: git push --force-with-lease origin main
# Matches: git push origin --force-with-lease main

Many Claude Code users experience unexpected confirmation prompts even after carefully configuring their allowlists. The prompt messages (“Command contains newlines…”, “Command contains quoted characters…”) do not clearly explain which rule failed or why. Users cannot tell whether this is a bug, an edge case, or a misconfiguration.

How runok handles this:

runok makes evaluation transparent. runok check shows exactly what decision was made, and --output-format json returns structured output with the matched rule, action, and reason:

Terminal window
$ runok check --output-format json -- 'git push --force origin main'
{
"decision": "deny",
"reason": "Force push rewrites history and is not allowed on this project.",
"fix_suggestion": "git push --force-with-lease"
}

The tree-sitter-bash parser handles edge cases (comments, compound commands, wrappers) that cause false positives in glob-based matching, eliminating the category of “unexplained prompts” entirely.

CapabilityClaude Code settings.jsonrunok
Configuration formatJSON (no comments)YAML (comments supported)
Pattern matchingSimple glob (* wildcards)Wildcards, alternation, optional groups, negation
Flag orderPosition-dependentOrder-independent
Comments in commandsBreak matchingStripped by tree-sitter-bash
Compound commands (&&, |, ;)Text-based heuristics cause false positivesDecomposed and evaluated individually via AST
Deny feedbackPattern only, no messagemessage + fix_suggestion fields
Subshell/wrapper parsingNot inspectedRecursive unwrapping (sudo, bash -c, $())
Per-command sandboxingSame restrictions for allPer-rule sandbox presets
DebuggingLimitedrunok check with JSON output