dsh-permission-rules rule file format

September 10, 2026 · View on GitHub

The rule file is a YAML document, discovered per session working directory: <cwd>/.dsh/rules.yaml by default. The rulesFile config can rename it or pin an absolute path; searchUp: true additionally merges every matching file from the session cwd up to the filesystem root (nearest first, so a child can override a parent); when discovery finds nothing, fallbackPath is used; with neither, an empty rule set applies (everything passes through).

A JSON Schema for this format ships at rules-format.schema.json; wire it into editors with the first line:

# yaml-language-server: $schema=https://raw.githubusercontent.com/PerryLink/dsh-permission-rules/main/docs/rules-format.schema.json

Top-level structure

rules:        # rule list, evaluated in written order; may be omitted or empty
  - match: ...
    action: ...
    reason: ...

Only rules is allowed at the top level; each rule allows only match, action, reason, enabled, description, tags; each match allows only tools, agents, params, paths, absent, when, network. Any unknown field fails the load loudly — never silently ignored.

match dimensions (AND)

DimensionTypeSemantics
toolsstring[]Tool-name globs (always globs, regardless of patternMode). Empty/absent = unrestricted. Supports prefixes like mcp__*.
agentsstring[]Agent-identity selector globs against the caller's session header candidates: main (top-level sessions), subagent (subagent children), and preset:<name> (when a preset composed the agent). Any selector matching any candidate satisfies the dimension; empty/absent = unrestricted. Unknown identity (no candidates) never matches, so agent-scoped rules fail closed.
paramsRecord<string, string | number | boolean | (…)[]>Argument key → pattern (or pattern list). Every listed key must be present AND match (AND); absent = unrestricted. Scalars match as strings (numbers/booleans stringified); array elements match any-of; nested objects contribute their scalar leaves (depth-capped at 8). A !-prefixed pattern negates: the value must NOT match it; a key with only negations matches when the key is present and no negation hits (quote ! patterns in YAML: "!git*"). / is an ordinary character in params patterns — * crosses it.
pathsstring[]Workspace-relative path patterns; any candidate matching any pattern satisfies the dimension. Candidates come from argument values under these keys at ANY nesting depth (depth-capped): path paths file files file_path dir directory directories cwd workspace root target targets output. Absolute candidates outside the workspace are dropped; relative ../ forms are kept so explicit out-of-root globs still work. In path patterns * does not cross /; ** crosses any depth (including zero). With caseInsensitivePaths on (Windows default) the root comparison and the patterns ignore ASCII case.
absentstring[]Argument keys that must be ABSENT; every listed key must be missing (non-object arguments satisfy this trivially).
when{ env, platform }Host conditions: env is env-var name → pattern(s), every listed var must be present AND match; platform is any-of a closed list (aix android darwin freebsd linux openbsd sunos win32).
argv{ command, args, anyArg, pipeline }Shell command decomposition scope: matches a command-string argument (command/cmd/script/command_line/commandLine) whose lexical decomposition satisfies every listed field. command matches any simple-command word; pipeline matches the command words joined by `
network{ domains, ips, ports, schemes }Network scope (a network rule): the call must carry a URL candidate satisfying every listed dimension. domains (subdomain-inclusive unless wildcarded), ips (literals/globs/CIDRs), ports (*, one port, or a range), schemes (http/https). See the next section.

All dimensions are ANDed: a rule matches only when every non-empty dimension matches. Rules evaluate in order — the first match wins; no match = passthrough.

network dimension

A rule whose match carries a network block is a network rule. It matches a tool call only when the call carries a URL candidate — a web-tool URL argument (url, endpoint, webhook, …) or a URL embedded in bash/pwsh command text — that satisfies every listed dimension (AND); at the proxy layer it matches the shell subprocess connection target directly. A rule WITHOUT network keeps its file/command behavior exactly as before.

FieldSemantics
domainsDomain patterns, lowercased with a trailing dot stripped. A pattern without wildcards is subdomain-inclusive — example.com also matches api.example.com; *.example.com matches subdomains only; */** compile as ordinary globs. ANY pattern matching the target host satisfies the dimension.
ipsExact IPv4/IPv6 literal, a glob (10.0.*.*), or an IPv4 CIDR (10.0.0.0/8). A literal IP in the URL is always a candidate; the proxy additionally resolves hostnames and tests their addresses, then connects on the adjudicated address (no second resolution, so DNS rebinding cannot move the connection to an address the rules never approved; a multi-address host falls back in adjudicated order), while tools/pre-execute matches literal IPs only (no DNS in the hot path). An IPv4-mapped IPv6 literal (::ffff:169.254.169.254, ::ffff:a9fe:a9fe, 0:0:0:0:0:ffff:a9fe:a9fe) normalizes to its dotted IPv4 form for matching, and a rule written in the mapped form also covers the plain IPv4 spelling. With network.upstreamProxy configured, a chained request has its DNS resolved by the upstream, so an ips-scoped decision deliberately does not chain and keeps dialing the adjudicated address. ANY match satisfies the dimension.
ports*, one port (443), or an inclusive range (8000-9000); numeric YAML ports are accepted. Evaluated against the effective port (URL port, else 80/443 by scheme). ANY match satisfies the dimension.
schemeshttp and/or https; the target scheme must be one of them.

A network block must name at least one of the four fields. Scope a network rule by tool with tools: [bash, pwsh] (shell traffic), tools: [web_fetch, web_search] (web tools), or leave tools empty to cover both. The three policy modes that decide unlisted targets (deny-all / whitelist / allow-all, with an auto mode mapped onto the sandbox presets) are plugin config, not rule syntax — see the README "Network policy" section.

rules:
  # block one host for web tools; the shell proxy enforces the same for curl
  - match: { tools: [web_fetch, web_search], network: { domains: [blocked.example] } }
    action: deny
    reason: "blocked.example is off-limits"

  # allow only pinned package registries over https (whitelist-friendly rule)
  - match: { tools: [bash, pwsh], network: { domains: ["registry.npmjs.org", "proxy.golang.org"], schemes: [https] } }
    action: allow
    reason: "pinned package registries"

argv dimension (shell command decomposition)

A rule whose match carries an argv block matches a command-string argument — the command/cmd/script/command_line/commandLine values, at any nesting depth — after lexically decomposing it into simple commands. The decomposition is quote/escape/pipe/control-operator/redirect aware and produces, per simple command, a command word, argument tokens, and redirect targets. This gives token-precise matching that the raw params.command substring globs cannot express (e.g. rm -rf /tmp is NOT rm -rf /).

FieldSemantics
commandCommand-word globs. ANY simple-command word matching ANY pattern satisfies the field.
pipelinePipeline-signature globs: matched against the command words joined by `
argsArgument-token patterns; EVERY pattern must match at least one token (AND across patterns, any-of across tokens). A !-prefixed pattern negates (no token may match it).
anyArgArgument-token patterns; ANY token matching ANY pattern satisfies the field (OR). A !-prefixed pattern negates.

All four fields AND together; a block must name at least one. Argument tokens include redirect targets (echo x > /etc/passwd feeds /etc/passwd to args/anyArg). command and pipeline are always globs; args and anyArg follow patternMode. Rules are typically scoped with tools: [bash, pwsh], but the dimension itself is tool-agnostic (any tool carrying a command string feeds it).

rules:
  # token-precise: rm -rf / but NOT rm -rf /tmp
  - match: { tools: [bash, pwsh], argv: { command: "rm", args: ["-rf", "/"] } }
    action: deny
    reason: "rm -rf / wipes the filesystem root"

  # download-and-execute: any pipeline whose stages are curl/wget then sh/bash
  - match: { tools: [bash, pwsh], argv: { pipeline: ["curl*|*sh", "wget*|*bash"] } }
    action: deny
    reason: "piping a remote fetch into a shell executes remote code"

action, reason, and metadata

  • action: allow | deny | ask (required).
  • reason: string (required, non-empty). A deny reason becomes the model-visible error in the denied tool result; an ask reason becomes the approval reason on the official approval seam.
  • enabled: false keeps the rule visible but inert (displayed as disabled); description and tags are free-form annotations shown by /rules.

Pattern interpretation (patternMode)

params, paths, and when.env patterns follow the plugin config patternMode (default glob):

  • glob: *, **, ?, [abc]/[!abc] character classes, \x escapes. In params, * crosses /; in paths, * stays in one segment and ** crosses segments. Invalid globs (unclosed [, empty class, dangling escape) fail the load, and a pattern expanding to more than maxGlobStars (default 2) unbounded star quantifiers is rejected — the backtracking degree of a compiled glob equals its star count. Split wider patterns into several.
  • regex: unanchored JavaScript regex (RegExp.test semantics); invalid regexes fail the load. Nested unbounded quantifiers ((a+)+, (ab*)+) and quantified groups with overlapping literal alternation branches ((a|aa)+) are rejected as catastrophic-backtracking risks; chains of independent quantifiers (\d+\.\d+\.\d+) stay allowed. In YAML double-quoted strings, \ needs escaping — single quotes are recommended.

Rule count cap

A total rule count above maxRules (default 256) fails the load — across the whole merged chain under searchUp. This is the simple bound for the tools/pre-execute hot path: tool-name globs, param keys, and path candidates are all precompiled regexes; matching is O(rules × patterns per dimension).

Security baseline example (5 rules)

# .dsh/rules.yaml — recommended security baseline
rules:
  # 1. Dangerous commands: destructive/forceful shell commands go to the
  #    second model (or a human) first
  - match:
      tools: [bash, pwsh]
      params:
        command: ["rm -rf*", "git push*--force*", "git push* -f*", "*:(){ :|:& };:*"]
    action: ask
    reason: "Dangerous command needs a second-model verdict"

  # 2. Protected paths: deny any tool operation on secrets/credentials/config
  - match:
      paths: ["**/secrets/**", "**/.env", "**/*.pem", "**/*.key", "**/.ssh/**"]
    action: deny
    reason: "Protected secrets and credential paths are off-limits"

  # 3. Releases: package publishing needs confirmation
  - match:
      tools: [bash, pwsh]
      params:
        command: ["npm publish*", "pnpm publish*", "cargo publish*"]
    action: ask
    reason: "Publishing needs confirmation"

  # 4. Remote side effects: pushing to main/master needs confirmation
  #    (two-star globs — maxGlobStars caps unbounded star expansions at 2)
  - match:
      tools: [bash, pwsh]
      params:
        command: ["git push*origin main*", "git push*origin master*"]
    action: ask
    reason: "Pushing to the main branch needs confirmation"

  # 5. Write confirmation: editor-class tools (and MCP tools) ask before writing
  #    (with dsh-auto-review mounted, these asks get an automatic second-model verdict)
  - match:
      tools: [edit, write, bash, pwsh, mcp__*]
    action: ask
    reason: "Write operations need confirmation"

Built-in high-risk baseline

The plugin ships a built-in high-risk baseline at rules/builtin-high-risk.yaml (deny/ask rules for destructive commands, privilege escalation, download-and-execute, and sensitive paths). It is enabled by default and appended AFTER every user rule file (project chain → searchUpfallback), so first-match semantics let any nearer user rule override it; with no user rules it applies alone. /rules attributes the baseline rules to their shipped source file.

  • Disable entirely with builtin.enabled: false.
  • Swap the file with builtin.path (absolute, or relative to process.cwd()).
  • The baseline is a deployed, read-only file: it is validated at mount (missing/invalid fails loud), it is never watched, and the settings-page editor refuses to write it.

The shipped baseline is an example, not an exhaustive policy — extend it with your own project rules.

Division of labor with dsh-auto-review

This plugin only produces ask decisions; the ask goes to the official ctx.approval approval seam. With dsh-auto-review mounted, its answerer claims matching requests and delivers a second-model verdict; otherwise a human answers; with neither, the official seam fails closed (unavailable → denied). This plugin never starts a reviewer subagent.

Loading and hot reload

  • Lazily discovered per session cwd and cached on the first tools/pre-execute (least-recently-used eviction beyond maxCachedWorkspaces); the cache key is the resolved cwd (case-folded on Windows), so differently-spelled paths share one entry.
  • Invalid YAML, unknown fields/actions, bad globs/regexes, backtracking-prone patterns, and counts over maxRules are load-time errors: badFilePolicy: fail (default) makes the first tool call in that workspace fail loudly; ignore-with-warning warns and degrades to an empty rule set.
  • An absolute rulesFile or a configured fallbackPath is validated at plugin mount — missing or invalid fails the mount.
  • File changes reload through Chokidar with a debounce (watch, watchStabilityThresholdMs); a failed reload keeps the previous rules and only warns — never crashes. /rules reload triggers the same path manually; /rules decisions and /rules test inspect the trail and dry-run the rules.
  • Expected-but-absent rule files (the project file when it is not in effect, a fallback after it was deleted) are watched through their deepest existing ancestor directory: creating one mid-session is adopted automatically without a manual reload. Under searchUp only the immediate cwd-level candidate is watched — creating a rule file in a deeper ancestor still needs /rules reload.
  • /rules lists the active rules (with list as an explicit alias); in multi-file chains every rule line is attributed to its own source file.
  • /rules test accepts leading flags: --cwd <dir> evaluates against another workspace (rule discovery AND path normalization), --env KEY=VALUE (repeatable) overrides host env for when.env, --agent <selector> (repeatable) supplies identity candidates for the agents dimension, and --platform <name> overrides the host platform for when.platform.
  • enforce: false puts the plugin in dry-run mode: deny/ask hits are audit-logged with a dryRun marker (keeping the would-be action and the real downstream outcome) and every call passes through — use it to trial a new policy in production before enforcing it. /rules prints a dry-run notice while the mode is active.
  • The settings page's Allow action inserts a match.network.domains allow rule at the HEAD of the nearest rule file that actually judges the connection (rules are first-match-wins, so one appended behind an existing matching deny would be dead text), and the write re-reads the rule chains immediately — including the session-less host chain — so no restart is needed.