# dsh-permission-rules rule file format

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
# yaml-language-server: $schema=https://raw.githubusercontent.com/PerryLink/dsh-permission-rules/main/docs/rules-format.schema.json
```

## Top-level structure

```yaml
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)

| Dimension | Type | Semantics |
|---|---|---|
| `tools` | `string[]` | Tool-name globs (always globs, regardless of `patternMode`). Empty/absent = unrestricted. Supports prefixes like `mcp__*`. |
| `agents` | `string[]` | 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. |
| `params` | `Record<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. |
| `paths` | `string[]` | 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. |
| `absent` | `string[]` | 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 `|`; `args` requires every pattern to match some token; `anyArg` requires some token to match a pattern (both support `!` negation). Argument tokens include redirect targets. See the next section. |
| `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.

| Field | Semantics |
|---|---|
| `domains` | Domain 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. |
| `ips` | Exact 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. |
| `schemes` | `http` 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.

```yaml
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 /`).

| Field | Semantics |
|---|---|
| `command` | Command-word globs. ANY simple-command word matching ANY pattern satisfies the field. |
| `pipeline` | Pipeline-signature globs: matched against the command words joined by `|` (e.g. `curl http://x \| sh` → `curl\|sh`). ANY pattern matching the signature satisfies the field. |
| `args` | Argument-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). |
| `anyArg` | Argument-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).

```yaml
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)

```yaml
# .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 → `searchUp` → `fallback`), 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.
