{"slug": "steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run", "title": "Steer: Deny or rewrite an AI agent's tool calls before they run", "summary": "Steer, a new rule engine for AI agents, intercepts and can deny, rewrite, or annotate tool calls before execution, with rules defined in TOML files. It addresses the problem that Claude Code's auto mode injects system directives that override user settings, making hooks the only layer with final control. The tool, installable via cargo, includes built-in rules, supports global and project-level configs, and evaluates rules to apply the strongest action (deny > rewrite > context).", "body_md": "A rule engine that sits in front of an agent's tool calls. It sees each call before it runs and\neither **denies** it with guidance the model reads, **rewrites** its input in place, or lets it\nthrough with **context** attached. Rules live in TOML, so redirecting a new tool is a config entry\nrather than another branch in a growing shell script.\n\nIt exists because a harness can outrank you. Claude Code's auto mode injects a system directive to\nsearch with shell `grep`\n\nand read with `sed -n`\n\n; it beats anything in `CLAUDE.md`\n\n, and no setting\nturns it off. A hook is the last layer that still gets to decide.\n\n``` bash\n$ steer check 'cd build && rm -rf dist'\ncommand  cd build && rm -rf dist\nsegments\n  head=cd args=[\"build\"] pipeline_start=true depth=0\n  head=rm args=[\"-rf\", \"dist\"] pipeline_start=true depth=0\nmatched  trash-over-rm\naction   rewrite\nrewrite  cd build && trash dist\ncargo install --path . --root ~/.local\n```\n\nThen register it in `~/.claude/settings.json`\n\n:\n\n```\n{\n  \"hooks\": {\n    \"PreToolUse\": [\n      { \"hooks\": [{ \"type\": \"command\", \"command\": \"steer hook --event PreToolUse\" }] }\n    ]\n  }\n}\n```\n\nNo config file is needed. The built-in rules are compiled into the binary and active on install.\n\nRegister `PostToolUse`\n\nthe same way once you have a `context`\n\nrule for it. None of the built-ins\nare, so adding it before then spends a process launch on every tool result and changes nothing.\n\nRules come from three places, each stacking on the one before:\n\n**Built-ins**, compiled into the binary and active with no config file at all.— your base, the one to keep in dotfiles. Honors`~/.config/steer/config.toml`\n\n`$XDG_CONFIG_HOME`\n\n.`steer init`\n\nwrites a commented starter here., found by walking up from the session's working directory — whatever this one project needs.`.steer.toml`\n\nin the repo\n\nA later source replaces an earlier rule of the same name, and `disable`\n\nswitches one off wherever\nit came from:\n\n```\n# .steer.toml — no fff index in this repo, but psql reaches a live database\ndisable = [\"fff-over-grep\"]\n\n[[rules]]\nname = \"no-prod-psql\"\ntool = \"Bash\"\n\n[[rules.match]]\nany = \"parsed.segments\"\nhead = { any_of = [\"psql\"] }\n\n[rules.action]\nkind = \"deny\"\nmessage = \"Use the read replica: psql -h replica.internal.\"\n```\n\nWhat a repo file cannot do is quietly soften a rule it does not name. Every matching rule is\nevaluated and the strongest action wins, so putting a `context`\n\nrule beside an inherited `deny`\n\nstill denies. Switching one off takes `disable`\n\n, by name, in the open.\n\n`steer validate`\n\nreports unknown fields, bad globs and regexes, duplicate rule names, and a\n`disable`\n\nnaming a rule nothing defines — with file and line.\n\n```\nsteer hook --event PreToolUse|PostToolUse   read a hook payload on stdin, decide on stdout\nsteer check '<command>'                     dry-run a Bash command through the rules\nsteer validate                              report problems in every config source\nsteer init                                  write a starter global config\n```\n\n`steer check`\n\nexits 1 when the command would be denied, so it drops into a script.\n\n`PostToolUse`\n\naccepts only `context`\n\nrules in v0.1. Denying or rewriting a call that already ran\nmeans nothing, and what else belongs there needs its own design pass.\n\nA tool call arrives as JSON: a tool name and that tool's input. steer copies it into a match\ndocument, adds `parsed.segments`\n\nwhen the tool is Bash, evaluates every rule against it, and\nreturns the strongest action any of them asked for — **deny > rewrite > context**. Reasons from\nevery matching rule are concatenated, so file order never changes the outcome.\n\nA complete rule, ready to copy:\n\n```\n[[rules]]\nname = \"no-curl\"\ndescription = \"Network fetches go through the WebFetch tool.\"\ntool = \"Bash\"\n\n[[rules.match]]\nany = \"parsed.segments\"\nhead = { any_of = [\"curl\", \"wget\"] }\npipeline_start = { is = true }\nargs = { none_glob = [\"http://localhost*\", \"http://127.0.0.1*\"] }\n\n[rules.action]\nkind = \"deny\"\nmessage = \"Use WebFetch — it renders the page and stays in the transcript.\"\n```\n\n`tool`\n\ngates on the exact tool name; leave it out and the rule sees every tool.\n\n**A rule fires when any of its [[rules.match]] blocks holds.** Blocks are alternatives — the\nshape a rule is looking for, written more than one way. The built-in\n\n`fff-over-grep`\n\nuses two: one\nfor `grep`\n\n-like commands, one for `git grep`\n\n.**A block holds when every condition in it holds against one binding.** This is what makes\ncorrelation work. The block above asks whether *one* segment has head `curl`\n\n*and* starts its\npipeline *and* is not pointed at localhost. Without a shared binding, `gh pr list | curl -X POST`\n\nwould match by taking the head from the second segment and the pipeline position from the first.\n\nEvery payload carries `tool_name`\n\nplus the tool's own input fields — `command`\n\nfor Bash,\n`file_path`\n\nfor Read and Edit. A Bash payload also gets `parsed.segments`\n\n, one entry per pipeline\nstage with its wrappers peeled:\n\n| Field | |\n|---|---|\n`head` |\nbasename of the command, so `/usr/bin/grep` and `grep` compare equal |\n`args` |\nits arguments, unquoted, with redirection targets removed |\n`pipeline_start` |\ntrue when the stage runs first in its pipeline |\n`in_workspace` |\ntrue when the stage reaches into the session's working tree |\n`depth` |\n0 for the command line, higher inside `bash -c` or `$(...)` |\n`wrappers` |\nwhat was peeled to get here |\n\n`;`\n\n, `&&`\n\n, `||`\n\nand newlines start a new segment; `|`\n\nonly advances the stage within one. A `grep`\n\nafter a pipe is filtering something that already ran, and `pipeline_start`\n\nis how a rule tells that\nfrom a search.\n\nPeeled as wrappers: leading `VAR=value`\n\nassignments — only when the name is a shell identifier, so\n`--include=\"*.go\"`\n\nsurvives — plus `env`\n\n, `sudo`\n\n, `time`\n\n, `nice`\n\n, `command`\n\n, `xargs`\n\n,\n`timeout <duration>`\n\n, and the shell keywords `if`\n\n, `then`\n\n, `elif`\n\n, `else`\n\n, `while`\n\n, `until`\n\n, `do`\n\n.\n`bash -c '...'`\n\nand `$(...)`\n\nre-enter the lexer on their script. `git`\n\nkeeps its head but loses its\nown leading options (`-C <path>`\n\n, `-c <k=v>`\n\n, `--git-dir`\n\n, …), so `args.0`\n\nis always the\nsubcommand. Heredoc bodies are data and produce no segments at all.\n\n`in_workspace`\n\nis about where an argument lands, not how it is spelled — an absolute path into the\nworking tree is the same search as the relative one, and agents write absolute paths constantly.\nPath-shaped arguments are resolved against the working directory (`~`\n\nexpanded, `.`\n\nand `..`\n\nfolded, no disk access), and the segment counts as inside when any of them lands there, or when it\nnames no path at all. An argument carrying glob or regex metacharacters is what the command is\nlooking for rather than where. The field is absent when the payload carries no working directory,\nso a rule asking for it declines rather than guessing.\n\nA condition key is a dotted path into the match document. Numeric parts index arrays: `\"args.0\"`\n\nis\nthe first argument, which is how `git grep`\n\nis told apart from `git commit`\n\n.\n\nA block binds to the whole document unless it names an array:\n\n| Key | Binding |\n|---|---|\n(neither) |\nthe document root |\n`any = \"<path>\"` |\nholds when some element of the array satisfies every condition |\n`all = \"<path>\"` |\nholds when every element does; an empty array never satisfies it |\n\n| Operator | Holds when |\n|---|---|\n`any_of = [...]` |\nthe value equals one of these |\n`none_of = [...]` |\nit equals none of them |\n`glob = [...]` |\nit matches one of these glob patterns |\n`none_glob = [...]` |\nit matches none of them |\n`matches = \"regex\"` |\nthe regex finds a match |\n`is = true` / `is = false` |\nthe value is that boolean |\n\nAn array value — `args`\n\n, say — satisfies a positive operator when any element does, and a negative\none only when no element does. Several operators on one key are ANDed.\n\nA path that resolves to nothing fails the positive operators and passes the negative ones: there is nothing there to match, and nothing there to violate.\n\n| Kind | Fields | Effect |\n|---|---|---|\n`deny` |\n`message` |\nthe call is refused and the message goes to the model as the reason |\n`rewrite` |\n`replace_head` , `drop_args` , `message` |\nthe call runs with edited input |\n`context` |\n`message` |\nthe call runs with the message attached |\n\nThere is no `ask`\n\n. A hook can rewrite tool *input* but cannot switch tools, so redirecting `grep`\n\nto a search tool or `sed`\n\nto a read tool can only ever be a deny with guidance; `rm`\n\n→ `trash`\n\nis a\nsame-tool correction and can be silent.\n\nA rewrite replaces the matched segment's head with `replace_head`\n\nand deletes any argument listed\nin `drop_args`\n\n, splicing into the original command string so everything else survives byte for\nbyte. It is held back — and the call allowed — when `replace_head`\n\nis not on `PATH`\n\n, or when the\nonly match is inside `bash -c '...'`\n\nor `$(...)`\n\n, where there is no span in the outer string to\nedit. That `PATH`\n\ngate lives in the engine, not in the rule, so it covers every rewrite ever\nwritten.\n\nCompiled into the binary and active with no config, covering every action type so none ships untested by anything real.\n\n| Name | Action | What it catches |\n|---|---|---|\n`fff-over-grep` |\ndeny | `grep` , `rg` , `find` , `git grep` and friends leading a pipeline over an indexed path |\n`read-over-sed` |\ndeny | `sed -n <range>p file` , which is a file read wearing a stream editor's clothes |\n`edit-over-python` |\ndeny | `python3 - <<'PY'` , a whole program written inline to do file surgery |\n`trash-over-rm` |\nrewrite | `rm` becomes `trash` , recursive and force flags dropped |\n\n`fff-over-grep`\n\nleaves three escapes open, each one a case where the guidance would otherwise be\nunfollowable: a search after a pipe (`gh pr list | grep foo`\n\n) filters output that already exists;\na search that lands outside the workspace, or under `node_modules`\n\n, is not something the fff tools\ncan answer; and a `find`\n\ncarrying an action primary (`-delete`\n\n, `-exec`\n\n) traverses in order to act.\n\nNothing steer does may block a call it did not mean to block. A hook that hard failed would take\nout every Bash call in every session, including the ones needed to debug it. So an unreadable\npayload, malformed JSON, a broken config, an unknown `--event`\n\n, or a panic all exit 0 and emit a\n`systemMessage`\n\nnaming the breakage. The release profile keeps `panic = \"unwind\"`\n\nfor that reason —\nan aborting panic could not be caught.\n\nOnly `steer hook`\n\nbehaves this way. `check`\n\n, `validate`\n\n, and `init`\n\nare developer tools and report\nfailures loudly.\n\nEvery deny, rewrite, and context injection appends a JSON line to\n`~/.local/state/steer/steer.jsonl`\n\n(or `$XDG_STATE_HOME/steer/steer.jsonl`\n\n) with the rule names,\noutcome, tool input, agent type, session, and working directory. Allowed calls are not logged. A\nfailed write is ignored — losing a log line is not a reason to interfere with a tool call.\n\nMIT", "url": "https://wpnews.pro/news/steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run", "canonical_source": "https://github.com/amalucelli/steer", "published_at": "2026-08-31 02:38:33+00:00", "updated_at": "2026-08-31 02:52:46.498627+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools"], "entities": ["Steer", "Claude Code", "cargo"], "alternates": {"html": "https://wpnews.pro/news/steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run", "markdown": "https://wpnews.pro/news/steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run.md", "text": "https://wpnews.pro/news/steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run.txt", "jsonld": "https://wpnews.pro/news/steer-deny-or-rewrite-an-ai-agent-s-tool-calls-before-they-run.jsonld"}}