For AI agents: this page as Markdown · llms.txt · JSON Schema

Writing command-line rules

Structural tool-call rules catch commands an agent should never run: git push --force, rm -rf, --no-verify. Every example ran through the engine. FLAG: the rule fired. PASS: it stayed quiet.

Internal testing: Rerail's latest release ships code rules only, not tool-call rules. See Internal testing.


Why a regex and not similarity

A structural rule states the exact command; a similarity threshold cannot.

Similarity and structural rules compared

As a similarity threshold, "never run git push --force" fires on neighbouring phrasings and misses the one spelling that matters.

Similarity rule Structural rule
Matches meaning, by embedding text, by regex / eq / contains
Needs a reference yes no, and rejects one
Cost per sample tokenize + embed (~80 µs) no model at all (~5 µs for 32 rules)
Sees tool arguments as one flattened JSON string field by field, e.g. input.command

Anatomy of a rule

A rule pairs a match condition with the tool calls it reads and what to tell the agent.

json
{
  "id": "oss:no-force-push",
  "mode": "steer",
  "match": { "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*\\s--force(?:\\s|=|$)" },
  "sample": { "source": "tool:Bash", "boundary": "tool-call" },
  "action": { "content": "Do not force-push: it rewrites published history. Ask the user first." },
  "repeat": { "mode": "always" }
}
What each part does
Part What it does
mode log records a match; steer tells the agent; block is stored as the intent to stop the call and, until block enforcement lands, is delivered exactly like steer.
match One field plus exactly one of regex, eq, contains — or all / any / not over conditions.
sample.source Which tool's calls to read: tool:Bash for one tool, tool:* for every tool.
sample.boundary Always tool-call for a rule that reads tool.name or input.
action What the agent is told when the rule fires.
repeat always suits command rules: every bad call deserves a steer. Default: once.

Fields

Command rules usually read input.command, one string argument. A missing or non-string argument never matches.

All fields
Field Reads Example value
input.command one string argument, by key git push --force origin main
input.<a>.<b> a nested string argument input.options.branch → main
input all arguments as stable JSON (keys sorted) {"command":"git push","description":"push"}
tool.name the tool's name Bash, shell, run_terminal_cmd
text the sample text, for any source an assistant sentence

Rules on tool.name or input see only tool calls, never tool results, so output that merely mentions git push --force cannot trip them.

The regex dialect

Patterns use the Rust regex crate, which runs in linear time: no command can make a rule hang.

In JSON, double every backslash: the regex \bgit\s+push\b is written "\\bgit\\s+push\\b".

Allowed syntax and limits

Dialect and 16,384-byte maxRegexPatternBytes limit match the code-rule DSL.

You may use You may not use
\b, \s, \w, [^\n], (?:…), {n,m} look-ahead / look-behind: (?=…), (?!…), (?<=…)
inline flags: (?i) ignore case, (?m) line anchors, (?s) dot matches newline backreferences: \1
named groups (?<NAME>…)

Seven steps to a good command regex

Build the force-push rule from a naive first attempt, fixing one failure at a time.

1. Start from the naive literal, and watch it fail

The literal misses extra spaces, later flags and -f, and fires on legit.

json
{ "field": "input.command", "regex": "git push --force" }
Results
Command Result
git push --force FLAG
git push --force PASS missed: two spaces
git push origin main --force PASS missed: flag after the arguments
git push -f PASS missed: short flag
echo 'legit push --force' FLAG false positive: legit

2. Bound the words and allow anything in between

\b stops legit matching git. \s+ absorbs any run of spaces. [^\n]* lets arguments sit between subcommand and flag, on the same line only.

json
{ "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*--force\\b" }
Results
Command Result
git  push origin main --force FLAG
legit push --force PASS fixed
git pushx --force PASS \b after push
git push --forced PASS \b after --force
git push --force-with-lease FLAG surprise — see step 3

3. End a flag with a delimiter, not \b

--force\b also matches --force-with-lease. End a flag with whitespace, = or end of command, and require whitespace before it.

json
{ "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*\\s--force(?:\\s|=|$)" }
Results and why

- is not a word character, so \b matches between --force and -with-lease.

Command Result
git push --force FLAG
git push --force origin main FLAG
git push --force=true FLAG
git push --force-with-lease PASS

Whether --force-with-lease should pass is policy; the corpus rule flags it: (?:--force\b|\s-f\b|--force-with-lease\b). Decide, then make the regex say so.

4. Cover short and bundled flags

Match any single-dash cluster containing the letter, so -f, -uf and -fu all count.

json
{ "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*\\s-[A-Za-z]*f[A-Za-z]*\\b" }

Flags are case-sensitive: leave (?i) off unless the tool ignores case.

Results
Command Result
git push -f FLAG
git push -uf origin x FLAG bundled
git push --follow-tags PASS \s- needs exactly one dash before the letters
git push origin feature-fix PASS \s- needs whitespace before the dash

git branch -D force-deletes, git branch -d does not:

Regex \bgit\s+branch\b[^\n]*\s-D\b Result
git branch -D old FLAG
git branch -d old PASS

5. Think about chained and multi-line commands

Agents chain commands with &&, ; and |. ^ anchors to the start of the whole argument, so anchor on a separator instead:

json
{ "field": "input.command", "regex": "(?:^|[;&|]\\s*)rm\\s+-[A-Za-z]*r" }
Results, and multi-line commands

A bare ^ misses every command after the first:

Regex Command Result
^rm\s rm -rf build FLAG
^rm\s cd x && rm -rf build PASS — missed

Anchored on a separator:

Command Result
rm -rf build FLAG
cd x && rm -rf build FLAG
ls | rm -r y FLAG
echo rm -rf build PASS

[^\n]* keeps the match on one line; a flag on a different line does not count:

Regex \bgit\s+push\b.*--force\b Result
git push origin main ⏎ echo --force PASS
git status ⏎ git push --force FLAG

Use (?m) to make ^ and $ match at each line.

6. Use not where you would reach for look-around

There is no (?!…). Write the exception as a second, not condition.

json
{
  "all": [
    { "field": "input.command", "regex": "\\brm\\s+-[A-Za-z]*r[A-Za-z]*f|\\brm\\s+-[A-Za-z]*f[A-Za-z]*r" },
    { "not": { "field": "input.command", "regex": "\\brm\\s+-\\w+\\s+(?:\\./)?(?:tmp|build|dist|node_modules)(?:/|\\s|$)" } }
  ]
}
Results
Command Result
rm -rf src FLAG
rm -rf ~ FLAG
rm -rf tmp/cache PASS scratch directory
rm -fr ./build PASS either flag order, optional ./

7. Know the limit: a regex does not parse shell

A regex sees characters, not shell syntax, so it cannot tell a flag from the same text in a quoted string:

Regex \bgit\s+push\b[^\n]*--force\b Result
git commit -m 'never git push --force' FLAG — false positive

This is rare and costs one unneeded steer. If it recurs, tighten the prefix (step 5); don't model quoting.


When not to use a regex

Use the simplest test that states the rule; often that is contains or eq.

Examples
Test Use it when Example Result
contains one literal is the whole signal "contains": "--no-verify" on git commit --no-verify -m x FLAG
eq exactly one full command is forbidden "eq": "npm publish" on npm publish FLAG
"eq": "npm publish" on npm publish --dry-run PASS
"eq": "npm publish" on npm publish (leading space) PASS

eq is brittle on free-form commands: an extra flag or a leading space makes it pass.

Beyond the command line

Match shells across hosts, paths under any argument name, and secrets in any argument.

Every host names its shell differently. Bind the rule to tool:* and name the shells:

json
{
  "match": { "all": [
    { "field": "tool.name", "regex": "^(?:Bash|bash|shell|run_terminal_cmd)$" },
    { "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*--force\\b" }
  ] },
  "sample": { "source": "tool:*", "boundary": "tool-call" }
}

Paths, whatever the argument is called. Write tools use file_path or path; cover both with any:

json
{ "any": [
  { "field": "input.file_path", "regex": "(?:^|/)\\.git/" },
  { "field": "input.path", "regex": "(?:^|/)\\.git/" }
] }

Secrets in any argument. input is all arguments as one JSON string, so one rule covers commands, file contents, and URLs:

json
{ "field": "input", "regex": "ghp_[A-Za-z0-9]{36}" }
Results and JSON escaping

Shell names:

Tool Command Result
Bash / shell / run_terminal_cmd git push --force FLAG
Write (an argument named command) PASS

Paths:

Path Result
.git/hooks/pre-commit FLAG
repo/.git/config FLAG
.github/workflows/ci.yml PASS
my.git/x PASS (?:^|/) requires a path boundary

input is JSON-escaped (\", and \n as two characters): keep its patterns free of quotes and line anchors. For commands, read input.command.


Test it, then install it

Test each rule against real cases, measure its noise, then install it.

1. Write the cases first. For each rule, list at least:

2. Run them through the engine. Put the rule and its cases in one conversation draft file, then test it:

json
{
  "kind": "conversation",
  "rule": {
    "id": "oss:no-force-push",
    "mode": "steer",
    "match": { "field": "input.command", "regex": "\\bgit\\s+push\\b[^\\n]*\\s--force(?:\\s|=|$)" },
    "sample": { "source": "tool:Bash", "boundary": "tool-call" },
    "action": { "content": "Do not force-push: it rewrites published history. Ask the user first." },
    "repeat": { "mode": "always" }
  },
  "cases": {
    "caught": [
      { "source": "tool:Bash", "boundary": "tool-call", "text": "{\"command\":\"git push --force origin main\"}", "input": "{\"command\":\"git push --force origin main\"}" }
    ],
    "passed": [
      { "source": "tool:Bash", "boundary": "tool-call", "text": "{\"command\":\"git push --force-with-lease origin main\"}", "input": "{\"command\":\"git push --force-with-lease origin main\"}" },
      { "source": "tool:Bash", "boundary": "tool-call", "text": "hint: use 'git push --force' to overwrite" }
    ]
  }
}
sh
rerail rules test no-force-push.draft.json

It exits non-zero when a case fails.

Draft format and drafting help

A tool-call case carries its arguments as stable JSON in both text and input; a tool-result case has text only. rerail rules test validates the rule, regex included, and runs every case without storing anything. rerail rules test --help prints the draft format. /rerail-create has your coding agent draft the rule and its cases.

3. Measure noise before blocking. Search your sanctioned commands (scripts, CI, docs) with the same pattern. rg uses the same Rust dialect, with single backslashes:

sh
rg '\bgit\s+push\b[^\n]*\s--force(?:\s|=|$)' scripts .github docs

Keep mode: "steer", not "block", while it still matches sanctioned commands, such as rm -rf in a cleanup script.

4. Install it. rerail rules add stores the rule in your library and adds it to the current checkout's repository:

sh
rerail rules add no-force-push.draft.json

It runs in Log until a person approves it: rerail rules approve oss:no-force-push or the dashboard (approval).

Install options and Rerail's own rules

--repo <dir> adds the rule to another repository. It applies at the draft's folder, or repository-wide without one. To change the tool-call rules in Rerail's own tests (fixtures/host/tool-call-rules/), follow CONTRIBUTING.md.

Errors you will meet

Each validation error names the part of the rule at fault; the table gives the fix.

Error messages and fixes
Message (shortened) Cause and fix
rule.match.regex is not a valid regex: regex parse error: … look-around, including look-ahead and look-behind, is not supported (?!…) or (?<=…). Use not (step 6).
rule.match.regex is not a valid regex: regex parse error: … backreferences are not supported \1. Spell the repeated text out.
rule.match.regex is 16385 bytes; maxRegexPatternBytes allows at most 16384 Split the pattern across any.
rule.match must contain exactly one of regex, eq, or contains Two tests in one condition. Wrap them in all.
rule.match.field must be text, tool.name, input, or input.<path> A typo such as input..command or command.
rule.sample.source must be tool:<name> or tool:* when the match reads tool.name or input The rule reads tool fields but samples a conversation source.
rule.sample.boundary must be tool-call when the match reads tool.name or input Set "boundary": "tool-call".
rule.reference must be omitted: a structural match computes no similarity Delete reference; it belongs only on similarity rules.
rule.sample.minimumTokens must be omitted: a structural match does not tokenize Delete minimumTokens.

Checklist

Check every rule against this list before installing it.