Next (unreleased)
This page tracks changes that will be included in the next release. It is updated as pull requests are merged.
Highlights
Section titled “Highlights”Breaking: quoted-delimiter HEREDOCs are no longer scanned for nested commands
Section titled “Breaking: quoted-delimiter HEREDOCs are no longer scanned for nested commands”Previously, runok recursed into the body of every HEREDOC looking for command substitutions ($(...), `...`) to evaluate as separate sub-commands, regardless of whether the delimiter was quoted. This did not match bash semantics: <<'EOF', <<"EOF", and <<\EOF make the body literal, so a $(secret_cmd) inside the body is text, not a real command. Scanning it caused false ask/deny decisions on commit messages and similar prose that happened to look like shell.
# Before: `secret_cmd` was extracted from the body and evaluated.# After: the body is literal, only `cat` is extracted.cat <<'EOF'$(secret_cmd)EOFUnquoted HEREDOCs (<<EOF) keep the existing behaviour — bash does expand the body, so runok still extracts substitutions from it.
What should I do?
If you previously relied on runok scanning a quoted-HEREDOC body (for example, a rule that fired because $(rm -rf /) inside <<'EOF' matched a deny rule), update the rule to target the actual command instead. Quoted heredocs are inert in bash, so this can only have hidden real commands behind a literal-looking surface — those should be written as ordinary command substitutions, not buried inside a literal heredoc.
Bug Fixes
Section titled “Bug Fixes”git commit -m "$(cat <<'EOF' ... EOF)" no longer fails with unclosed quote
Section titled “git commit -m "$(cat <<'EOF' ... EOF)" no longer fails with unclosed quote”Commit-message workflows that pipe a HEREDOC through cat inside a double-quoted command substitution — for example, the Claude Code /commit skill — were rejected with command parse error: unclosed quote. The character-level tokenizer used to fall back behind the AST walk treated the HEREDOC body as live shell, hit a stray quote inside the prose, and bailed out. The tokenizer is now AST-only: quotes are resolved per AST node, so a HEREDOC body is handled as the literal redirect target it is and never re-scanned as shell syntax.
# Before: command parse error: unclosed quote# After: matches the existing `git [-C *] commit -m *` rule and# evaluates to allow.git add path && git commit -m "$(cat <<'EOF'subject
body line 1 with 'apostrophes' insideEOF)"Quoted command names match the same rules as their unquoted form
Section titled “Quoted command names match the same rules as their unquoted form”"echo" hello (or 'echo' hello) used to tokenise with the surrounding quotes still attached to the command name (["\"echo\"", "hello"]), so a rule like allow: 'echo *' would not fire. Quotes are now stripped from the command name as well as from arguments, matching how bash itself treats them.
runok check --input-format claude-code-hook no longer blocks Claude Code on runok-side failures
Section titled “runok check --input-format claude-code-hook no longer blocks Claude Code on runok-side failures”In hook mode, the following runok-side failures now exit with code 1 instead of 2: config load errors, rule pattern parse errors, unknown-flag errors, stdin JSON parse errors, and HookInput schema mismatches. Previously, any of these would cause every Bash tool call in Claude Code to be blocked, because Claude Code treats exit 2 from a PreToolUse hook as a blocking error. Exit 1 is the documented non-blocking failure mode and lets Claude Code fall back to its normal permission flow until the underlying issue is fixed.
Direct CLI usage (runok check without --input-format claude-code-hook) is unchanged.
See runok check exit codes for details.
New Features
Section titled “New Features”Global --config / -c flag (#315)
Section titled “Global --config / -c flag (#315)”All subcommands now accept a -c / --config flag to load a specific config file instead of the default config discovery (global + project). The flag can appear before or after the subcommand name.
runok check -c readonly-gh.yml -- gh api graphqlrunok -c custom.yml exec -- npm testrunok test -c my-rules.ymlThis replaces the previous per-subcommand --config flags on runok test and runok migrate. The flag now works identically on all subcommands including check and exec.
See Global Flags for details.
Library API changes
Section titled “Library API changes”These changes only affect code that imports runok as a Rust library. The CLI and runok.yml authoring are unaffected.
Breaking: CommandParseError::UnclosedQuote removed
Section titled “Breaking: CommandParseError::UnclosedQuote removed”The UnclosedQuote variant is gone. Inputs that the previous character-level tokenizer rejected as UnclosedQuote are now reported as CommandParseError::SyntaxError, alongside everything else tree-sitter-bash refuses.
What should I do?
If you have a match arm on CommandParseError::UnclosedQuote, fold it into the SyntaxError arm:
// Beforematch err { CommandParseError::UnclosedQuote => /* ... */, CommandParseError::SyntaxError => /* ... */, CommandParseError::EmptyCommand => /* ... */,}
// Aftermatch err { CommandParseError::SyntaxError => /* ... */, CommandParseError::EmptyCommand => /* ... */,}Breaking: bare FOO=bar and trailing-\ inputs now report SyntaxError
Section titled “Breaking: bare FOO=bar and trailing-\ inputs now report SyntaxError”The previous tokenizer accepted a few inputs that bash itself does not consider a complete command:
- A bare
VAR=valueassignment (no command following it) used to tokenise as["VAR=value"]. - A trailing backslash (
echo \) used to silently drop the backslash.
Both now return CommandParseError::SyntaxError. tree-sitter-bash flags them as parse errors, and the shlex fallback also rejects them. End-to-end command evaluation is unaffected for ordinary inputs because compound input is split first by extract_commands_with_metadata, which still extracts substitutions out of VAR=$(cmd)-style assignments before tokenisation runs.
What should I do?
If you have integrations that fed parse_command raw assignment-only strings, wrap them in a real command (true VAR=value) or switch to evaluating via evaluate_command / extract_commands_with_metadata, which already handle assignments.