DSH HUB
HomePlugin StorePlugin PacksCommunityRankingsResourcesPublish Guide
Plugin source
Back to catalog

ruihe774 /

ruihe774/dsh-bash-guardrails

Verified

A dsh plugin that enforces customizable guard rules on bash tool calls before the agent runs a bash command

★ 0 Stars0 Forks0 IssuesN/A Community rating0 Confirmed installs
View on GitHub
READMESource: main@ed58b57f

dsh-bash-guardrails

A DeepSeek Harness (DSH) plugin that enforces guard rules on bash tool calls. Before the agent runs a bash command, the plugin checks it against a set of rules and, if one matches, blocks the call with a short explanation so the agent can correct course on its own. Each built-in rule is a toggle, and only two are on by default. You can add your own rules, written in CEL, in the same format the built-in rules use.

It is a port of cc-bash-guardrails (a Claude Code mod), sharing its rule engine, shell analysis and rule-file format. Requires DSH 0.2.1-rc.5 or a later 0.2 release.

Why not Auto review or a regex hook?

Auto review. DSH's experimental Auto review asks a model whether an action is unsafe or unauthorized. The pitfalls here are different: commands that are allowed and not malicious but reliably waste time or cause trouble, such as find / crawling the whole filesystem, pgrep -f matching the harness's own helper processes, or an until grep loop that never exits when the writer dies. These rules are deterministic, so the same command is always handled the same way, and each denial tells the agent what to do instead. They work alongside Auto review (see below).

Regex hooks. Matching the command text with a regular expression is brittle in both directions. echo "find / -name x" trips a regex for find /, while cd /x && find / -name y, bash -c "find / -type f", echo $(find /), \sudo ls or env X=1 timeout 5 find / slip past a simple one. This plugin parses the command with a real shell parser (a vendored copy of unbash) and checks the commands that would actually run, so quoting, escapes, pipelines, && chains, subshells, command substitutions, heredocs and bash -c strings are all understood. Commands behind wrappers such as env, timeout, nice, xargs, sudo and run0 are checked too.

What it does

Rule Default Blocks
malformed_bash on Bash commands that fail to parse. The agent gets the parse error and retries. The other rules rely on the parse: with this rule off, a command that doesn't parse may be only partly checked.
find_root on find rooted at / (including //, /* and find -- /), even with -xdev. It is independent of find_xdev.
find_xdev off find without -xdev or -mount (or BSD/macOS -x), so searches never cross filesystem boundaries. find --help and --version are left alone.
pgrep_f off pgrep -f without -a, and any pkill -f. -f can match helper processes spawned by the harness, and pkill would kill them; pgrep -af shows each command line so matches can be verified before killing by PID.
pgrep_captured off pgrep output captured by a pipe or substitution (pgrep x | wc -l, $(pgrep x), <(pgrep x)).
until_grep off until grep ... and while ! grep ... loops (also egrep, fgrep and rg), which never exit if the process writing the log dies. The agent is pointed to tail -f --pid=<PID> <log> | grep -m1 <pattern> instead.
sudo off sudo, including wrapped (env X=1 sudo ls) and nested (bash -c "sudo ls") forms. The agent is pointed to run0.
cat_read off cat that only shows files: cat f, cat -n a b, cat < f, and cat f piped into nothing but line pickers (head, tail, sed -n 'X,Yp', awk 'NR...'). The agent is pointed to the read tool. cat whose output goes into another command, a substitution or a file is left alone, as are cat -A/-v, files under /dev, /proc and /sys, and cat run through sudo, run0 or doas.
line_range_read off head f, sed -n 'X,Yp' f (also X,$p, X,+Np, sed 'X,Y!d' f and sed 'Nq' f) and awk 'NR>=X && NR<=Y' f showing lines of a file. The agent is pointed to read with offset and limit. A standalone tail is left alone, as are sed -z and commands run through sudo, run0 or doas.
heredoc_write off Writing a heredoc or here-string to a file: cat <<EOF > f, cat <<EOF >> f, cat <<EOF | tee f, tee f <<EOF. The agent is pointed to the write tool. A heredoc the shell expands (an unquoted delimiter with $ or a backquote in it) is left alone, as is a cat or tee run through sudo, run0 or doas.
sed_edit off sed -i on one file whose script is only s commands and deletions of a line or range. The agent is pointed to the edit tool, whose replace_all covers g. Several files, regex addresses, -n and -f scripts, and sed run through sudo, run0 or doas are left alone.
python_edit off A Python script, from python -c or a heredoc on python's stdin, that imports re or calls .replace(, opens a file (open( or pathlib), writes (.write(, .write_text(, .write_bytes( or .writelines() and has a multiline (triple-quoted) string literal. The agent is pointed to the edit tool.

Every rule is checked in the order above, then your custom rules, and the first match decides. A denied call does not run; the agent sees Error: <reason> No commands in this tool call have been run. Anything no rule matches runs as usual.

These rules are meant to catch common mistakes, not to be a security boundary. A command assembled at run time ($cmd, eval "$x") or run through a program that is not a recognized wrapper (ssh host find /, watch find /) is not seen.

When it runs

  • On every bash tool call: calls the model makes directly, bash calls inside a PTC run_code program, and calls made by in-process subagents.
  • Other tools are never touched. pwsh and the interactive terminal tool are not checked.
  • If the check itself fails, the call is blocked rather than run unchecked.

With Auto review

The check is a tools/pre-execute listener kept in front of every other one, Auto review included:

  • A call a rule denies never reaches the reviewer: no review request, no tokens, no approval prompt, and the agent gets the rule's explanation rather than a generic review denial.
  • A call no rule denies goes on to Auto review exactly as before.

Cordis runs the most recently prepended listener first, and Auto review prepends its listener when it is switched on, which can be after this plugin. So whenever another plugin prepends a tools/pre-execute listener, this plugin re-registers its own in front. Correctness doesn't depend on that ordering: a denial further down the chain still wins in Auto review, and a ctx.tools.guard() backstop denies a matching call even if some listener ahead of this one allows without passing it on. If two plugins kept fighting for first place, this one stops after 20 attempts in a second and logs a warning, and the guard still applies.

Installation

From a checkout:

npm install
npm run build

Then install the directory into your DSH profile, from the Web UI's plugin manager or with the CLI:

dsh plugin --profile web add /path/to/dsh-bash-guardrails

Configuration

Each built-in rule is a boolean option on the plugin row, named as in the table above (sudo, find_xdev, …). Only malformed_bash and find_root default to true. Change them in your profile's patch layer (~/.dsh/profiles/<profile>/cordis.patch.yml). An override replaces the row's whole config, and options you leave out keep their defaults:

- id: bash-guardrails
  name: dsh-bash-guardrails
  config:
    find_xdev: true
    pgrep_captured: true
    pgrep_f: true
    sudo: true
    until_grep: true

Custom rules

Add your own rules in a JSON rule file. Two files are read, and their rules run after the built-in ones, in this order:

  • Your rules: ~/.dsh/bash-guardrails.json (in $DSH_HOME if you set it)
  • The project's rules: .dsh/bash-guardrails.json in the session's directory

A rule file looks like this:

{
  "defs": {
    "FETCHERS": ["curl", "wget"],
    "RM": { "short": "rRfiIdv", "long": { "recursive": "r", "force": "f" } }
  },
  "rules": [
    {
      "id": "no-fetch",
      "let": { "hits": "cmds.filter(c, c.name in FETCHERS).map(c, c.name)" },
      "when": "size(hits) > 0",
      "deny": "Do not use {{ hits[0] }}; use the web_fetch tool."
    },
    {
      "id": "no-rm-rf",
      "when": "cmds.exists(c, c.name == 'rm' && c.args.opts(RM).exists(o, o in ['r', 'R']) && 'f' in c.args.opts(RM))",
      "deny": "Do not run rm -rf; delete the specific files you created."
    }
  ]
}

Each rule has:

Field
id Required. Letters, digits, - and _. It can't be a built-in rule's id.
when Required. A CEL expression that must be a bool. The rule fires when it is true.
deny Required. The reason the agent sees when the rule blocks the call.
let Optional. Named CEL values, in order, that when, the message and later lets can use.
tools Optional. ["bash"], the default and only tool. Bash is accepted as an alias, and Monitor (a Claude Code tool) is ignored, so rule files written for cc-bash-guardrails load unchanged.
enabled Optional. false turns the rule off.

deny is a template: {{ expr }} inserts the value of a CEL expression. There is no allow action: a custom rule can only make the guard stricter, so a project's rule file can at worst block calls, and can't turn off a built-in rule or one of yours.

ask rules (asking the user before a call runs) are not supported yet. A rule with ask is skipped and reported when its file loads.

What a rule sees

Name Type
tool string bash
input map<string, dyn> The tool's input: command, description, workdir, run_in_background
cmds list<Cmd> Every simple command the shell would run, including those in pipelines, && chains, subshells, substitutions, bash -c strings and behind wrappers such as env, timeout, xargs, sudo and run0
errors list<string> Parse errors. When it isn't empty, cmds may be incomplete.
untilConds list<list<Cmd>> For each until or while ! loop, the commands of its condition
pipelines list<list<Cmd>> For each pipeline (a | b | c), its stages in order. A stage that is not a simple command, such as { ...; }, has name ''.
your defs Constants from the file's defs, and the built-in ones (FIND_LEAD, FIND_EXPR, PGREP, GREPS, CAT, HEAD_TAIL, SED, AWK, TEE, PYTHON and the others in src/builtin-rules.ts)

A Cmd has name (the program's basename), args (its arguments, with quotes removed), captured (its output goes into a pipe or a substitution), wrappers (the wrappers in front of it, outermost first, including those in front of a shell running it, as in sudo sh -c "...") and chain, a list of Links, one for each wrapper's invocation and a last one for the command itself. A Link has name (the program's basename) and argv (its invocation as written, the program's path included). So env X=1 sudo -u root ls -l has name ls, args ["-l"], and a chain of env, sudo and ls whose argvs are ["env", "X=1", "sudo", "-u", "root", "ls", "-l"], ["sudo", "-u", "root", "ls", "-l"] and ["ls", "-l"].

A Cmd also has redirects, a list of Redirs: its own, then those of the compound commands around it ({ ...; } > f, for ...; done > f) and of a shell running it (bash -c "..." > f), innermost first. A Redir has op (>, >>, <, <<, <<-, <<<, >&, &> and so on), fd (the descriptor written before the operator, -1 if none, -2 for a {var} one), target (the file, descriptor or here-string word, or a heredoc's delimiter), body (the text a heredoc or here-string feeds in, or '') and quoted (a heredoc whose delimiter is quoted, so its body is not expanded). And stdout is where those redirects send its output: a file, &2 for another descriptor, or '' when it is not redirected.

On top of standard CEL, including macros such as exists, all, filter and map, cel.bind, and matches for regular expressions (JavaScript syntax), rules can use:

Function
args.opts(spec), args.operands(spec) Parse a list<string> of arguments by a spec and return the options seen, or the operands. A spec is a map with any of: short (getopt letters, a : after one that takes a value, :: after one whose value is optional and only in the same argument, as -i.bak), shortTakesValue (every letter in short takes a value, and only from the same argument), long (long option names mapped to the name to report, as {"full": "f"}; a name ending in :, as {"expression": "e:"}, takes a value from =value or else the next argument), words (find-style words, each mapped to how many arguments follow it, or to a list of terminators as {"-exec": [";", "+"]}), wordPatterns (the same, keyed by regex) and posix (stop at the first operand).
args.optValues(spec, name) The values given to option name (as reported by opts), in order: ['-e', 'a', '-eb'].optValues({"short": "e:"}, 'e') is ["a", "b"]
list.takeWhile(regex) The leading items that match the regex
list.take(n), list.drop(n) The first n items; all but the first n
shquote(argv) A list<string> as a shell-quoted command line
list.flatten(), list.distinct() Flatten a list of lists; drop repeated items

The built-in rules in src/builtin-rules.ts are written the same way, so they are worked examples.

When rules load, and when they fail

A rule file is read when a session starts and again whenever it changes (by modification time and size), so edits apply to the next call without a restart. Each rule is type-checked when it loads: a typo such as c.nmae, cmds.name == 'find' (a list has no name) or a when that isn't a bool is caught there. A rule, def or file that fails to load is skipped, and a warning in the DSH host log names it and the error; the other rules keep working. A rule that fails while it runs, for example on a malformed argument spec, blocks the call.

What it runs, sends, and fetches

  • It runs inside the DSH host process: a tools/pre-execute listener, a tool guard, and an agent/created listener that loads the rule files early so problems are reported when a session starts.
  • It makes no model calls, no network requests, and no shell commands. Commands are only parsed, never executed.
  • It reads only its two rule files and writes no files. To find the first, it reads DSH_HOME and your home directory.
  • It logs the id of each rule that denies a call (not the command) and rule-file problems to the host log. It does not collect, store, or transmit any data.
  • Its dependencies are vendored and have no install step: the unbash parser (ISC license, in src/vendor/unbash/) and the cel-js CEL interpreter (MIT license, bundled into src/vendor/cel/). At run time it imports only @deepseek-ai/schemastery, which DSH supplies.

Development

The rule logic is pure and kept free of the DSH API: the CEL engine (compileFile, decide and decideDenySync) in src/engine.ts, the argument parser in src/argv.ts, and the parser wrapper analyze in src/shell.ts. The built-in rules are data in src/builtin-rules.ts, compiled in src/rules.ts. src/rule-files.ts loads the rule files, src/keep-first.ts keeps the listener in front, and src/index.ts is the plugin (name, inject, Config, apply). src/dsh.ts mirrors the few DSH types used.

npm test          # compiles to .test-out and runs node --test
npm run typecheck
npm run build     # src -> lib

tests/rules.test.ts covers the built-in rules, tests/engine.test.ts custom rules and the engine, and tests/plugin.test.ts the plugin on the real DSH tool pipeline (@deepseek-ai/dsh-tools 0.2.1-rc.5), including ordering against a later prepended listener in Auto review's shape.

License

The plugin's own code is released into the public domain under the Unlicense. The vendored code is not public domain: the unbash parser in src/vendor/unbash/ is Copyright (c) Lars Kappert and licensed under the ISC License, and the cel-js interpreter in src/vendor/cel/ is Copyright (c) 2025 Marc Bachmann and licensed under the MIT License, so the combined work is Unlicense AND ISC AND MIT. Their copyright and permission notices must be preserved in all copies; see THIRD_PARTY_NOTICES.md, src/vendor/unbash/LICENSE and src/vendor/cel/LICENSE.

—/ 5

No ratings yet

Verified DSH bundle

Commit ed58b57f658c

Community comments

No comments yet. Be the first to write one.

DSH HUB

A community index for DSH plugins. Not an official GitHub or DeepSeek AI product.

CommunityResourcesAPIAbout