JayYuen666/dsh-danger-guard

JayYuen666/dsh-danger-guard

执行前拦截:`tools.guard` 谓词按当前权限档位,对危险 shell 命令(跳过 git 钩子、灾难性 `rm`、下载即执行、重写 git 历史)与「写凭据路径」「没读过目标文件的首次编辑」逐条给出放行 / 询问 / 拒绝,并把成因与可行替代写法交回模型。

安全与权限danger-guarddeepseek-harnessdsh

dsh-danger-guard

A pre-execution guard for dsh: dangerous shell commands and credential edits are physically refused before dispatch, and the model is handed the reason plus a working way out.

Contents

  • What it does
  • Install
  • Verify it is live
  • What it does not provide
  • Enabling it in dsh
  • What the rules block
  • Settings
  • What each permission preset blocks
  • Known boundaries
  • Data and privacy
  • FAQ
  • Development
  • License

What it does

  • It blocks, it does not advise. The decision lives in the synchronous predicate on ctx.tools.guard: returning a string rejects that call. The host turns the reason into the call's error result, so the model reads "why this was refused, and what to write instead" - not a silent failure.
  • Three defences:
  • Dangerous bash / pwsh commands (bashVerdictOf in lib/danger-rules.ts): git hook bypass, catastrophic rm, download-and-run, long-running dev servers, the git destruction family, and the infrastructure destruction family.
  • Credential and secret paths on edit/write tools (secretPathDeny) - matched by argument key shape, never by tool name, because tool names drift with the provider.
  • The editor path face has a second refusal key, opaqueTarget: a target longer than the judgable length limit is neither confirmed nor cleared as a credential, so it is denied with a "paste the path to the user, do not retry verbatim" message. Same principle as nestedShellUnconfirmed on the bash side - cannot judge is not the same as safe. (An over-long *command* resolves to nestedShellUnconfirmed instead.)
  • The first-edit fact gate (lib/fact-gate.ts): an edit that has not read its target, or that touches a risky surface without layer evidence, gets refused with the missing evidence attached.
  • "Has read" counts successful calls only: the evidence ledger joins tool/call to tool/result by callId, and a call without one has unknown status and does not count (the host appends the call before dispatching, so a call that never ran or already failed looks exactly like a successful one in the log).
  • "Which revision was read" lives in lib/read-basis.ts: the observation baseline and the staleness point are one record, and it is committed only after a successful result - a failed or blocked read cannot advance the baseline and thereby swallow an external change. Once an external change is attributed it stays in force until a successful read actually lands (or the preflight hands the body over in a deny reason).
  • Preflight gathers that evidence itself (two faces on two waterfalls, split by when evidence actually becomes visible):
  • Face A, on tools/post-execute: a read-only inspection (read / view / str_replace_editor view) returns its reference surface *with that read's result* (PostToolDecision.accept{additionalContexts}), so the model sees "who references this" before its next edit.
  • Face B, on tools/pre-execute: a write that never read its target gets the target content and reference surface written into that call's denial reason (PreToolDecision.deny{reason}); an identical retry then passes on the delivery watermark. The physical stop stays before dispatch, so "see it before you change it" is not negotiated away.
  • This is why not agent.inject(): that API queues context for the *next* pre-step, while decisions are made per step - evidence would arrive *after* the edit landed.
  • The scan is bounded by hard caps (files, directories, per-file bytes, findings) and merges cancellation with a wall clock; the platform never abandons a pending pre-execute promise, so unbounded work is not an option.
  • The credential rule: "the traversal was not truncated" or "references were found". A correctly scoped search by the model is itself not exhaustive, so demanding exhaustiveness here would be stricter than the gate. But "zero hits and truncated" grants nothing - every false negative lives there, and half an "I looked" is worse than none.
  • A zero-hit layer conclusion requires that subtree to be fully read: the scan records files it walked past without reading their bodies (over the size cap, quarantined credentials, unreadable). If the target layer still holds any of those - or the traversal itself was truncated - that layer gets no "0 hits" conclusion. Otherwise a truncation *inside* the layer would claim "no references touch this layer" while the file that references it sits past the budget. A found hit is exempt: evidence of existence needs no exhaustiveness.
  • Every refusal carries its way out. BashVerdict holds a structured Fix: the git bypass names the offending flags, catastrophic rm names the offending targets (excluding the safe ones). The model gets "change these words and it passes", not a verdict.
  • On an `ask` hit, one read-only blast-radius rehearsal runs (fixed command table, no model input interpolated) and its output is rendered per locale into the approval request's reason and displayReason. Empty output only means the probe printed nothing; it does not prove the original action changes nothing. A command carrying -C/--git-dir/--work-tree, a failed policy resolution, an unknown session cwd, or host-truncated output falls back to a blind ask.
  • Repeats are not re-reported (AskDeduper): same session + same rule key + same command text no longer goes back to the lesson bus and its rehearsal is not recomputed - but the verdict and the prompt stay exactly as they were. What this suppresses is lesson-card fragmentation, not a user interruption; deduplication must never become a silent pass.

How a verdict is decided

  • Only bash and pwsh command strings are judged; a non-string command passes (no guessing). The string is split at ;, &&, ||, |, newline, &, (, ), backtick, with no splitting inside quotes.
  • Segment splitting, word lexing, the structure view and the unclosed-quote check are four projections of one POSIX state machine (lib/command-segments.ts). Two different machines would give the same string opposite verdicts in different predicates.
  • Wrapper words (sudo, env, time, nice, stdbuf, timeout, taskset, watch, setsid, doas, pkexec, nohup, exec, command, xargs, parallel, leading VAR=x), binary path prefixes (matched by basename, so /usr/bin/sudo is no escape), quoting, and backslash continuations are not escape hatches. A quoted flag or quoted command word is a working spelling: git commit "--no-verify" really skips the hook, and "rm" -rf ~ judges like the unquoted form.
  • Because a per-tool option table can never be complete, a wrapper-led segment also treats every following word position as a candidate command head.
  • Principle: when unsure, let it through - with one exception, an execution face you cannot see. An unextractable nested -c / eval body, nesting past the drill-down limit, a segment head that is a non-shell interpreter running a code string (python3 -c, node -e, perl -e, php -r…), 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.
  • Severity ordering: when one command holds both an ask segment and a deny segment, the whole string resolves as deny (the scan continues until it has one). Without this, the leading ask would end the scan early and the later catastrophe would never be judged.

Install

dsh plugin --profile web add @jayyuen66/dsh-danger-guard

Remove with dsh plugin --profile web remove @jayyuen66/dsh-danger-guard. The package is on the public npm registry, so installation needs no credentials and no scoped-registry line.

  • Requires Node.js `>= 22.12`. This is an ESM-only package; that Node floor is what makes require() of an ESM entry work at all, which is why engines.node is declared.
  • Requires host `dsh` at the `0.2.1-rc.3` line. The requirement the host actually reads is engines.dsh (^0.2.1-rc.3), and it decides alone: evaluatePluginCompatibility returns on the engines.dsh branch before it ever looks at peerDependencies, so the @deepseek-ai/dsh* peers are not consulted for compatibility here. Prereleases participate in the comparison. A host on any 0.2.x satisfies that range; one on 0.3.0 or later does not, and needs the explicit risk grant dsh plugin --profile allow-version @ --dsh-version --accept-risk. The exact pins in peerDependencies are a separate, compile-time discipline (enforced by test/publish-manifest.test.ts): this plugin's types and nominal brands are coupled to the host ABI, and silently following it upward is how a second copy of the host type surface ends up loaded.
  • **No @deepseek-ai/* under dependencies, by contract (`test/publish-manifest.test.ts` enforces it). Consumer profiles install with `nodeLinker: hoisted` and `autoInstallPeers: false`, so a host package under `dependencies` is materialized as a real directory next to the copy the running host already loaded. `@deepseek-ai/dsh-tools` keys its tool-runtime scheduler with a plain `Symbol` (not `Symbol.for`), so symbol identity is per module instance: the host reads the scheduler off the profile copy, gets `undefined`, and every native tool call throws** - while build, typecheck and coverage gates stay green right up to that moment. Peers and devDependencies never enter the profile, so they cannot produce a second copy.
  • Host packages imported for value stay externalized. @deepseek-ai/dsh-brand and @deepseek-ai/schemastery are value imports (brandNumber, import Schema from …). The externalization list in build-host.mjs reads dependencies ∪ peerDependencies; a devDependencies-only entry would let the bundler inline the official function body, giving this package its own copy of the official constructor. A required peer satisfies both halves: the specifier stays external, and the profile installs nothing extra. zod is a plain dependency (imported for value by lib/tool-ledger.ts).
  • The artifact is not minified on purpose. test/build-host.test.ts pins the bare specifiers in host.js and their absence from settings.js; minification would remove those strings and turn the assertions permanently green.
  • TypeScript declarations ship (types/host.d.ts, types/settings-host.d.ts, plus the lib/ declarations they reference), each subpath carrying a types condition so tsc resolves them under node16 / bundler. ./client deliberately has none (it is a browser UMD shell) and neither does ./cordis.patch.yml (the host loader reads it as a file).
  • Build artifacts are not committed; prepack rebuilds them, so what ships is always what the current source produces.

Shared version floor

If your profile pins @jayyuen66/dsh-plugin-shared with a pnpm overrides entry, that pin must be at least the version this package declares. A lower pin installs a copy without the names imported at runtime, and the host then reports N entries did not activate together with does not provide an export named ... at boot - the package installs fine and never loads, and no per-repository CI run can see it.

Verify it is live

  1. Mounted: the settings page lists a "danger-guard danger block" row, and dsh --profile web --dump-config shows this package's layer.
  • --dump-config is the only accepted "it is in the layer stack" signal, and it is not read-only - it rewrites that profile's root assembly file. Pass/fail therefore reads layer lines from stdout and checks stderr separately; an unmatched patch target appears only on stderr, so the exit code alone proves nothing.
  • A layer line may read # == or # == , patched by ; any check must accept the second form or it will report "not installed" about a layer that is really there.
  1. Blocking: in a preset that blocks it (read-only, workspace write and full access all deny it; auto review allows it), ask the agent to run git commit --no-verify. The call itself must fail with Error: and name a way out. A "no change in behaviour" result there means the gate is not mounted, not that the rule is lenient.
  2. Settings: open the card, toggle a value, Save becomes enabled, and the write returns 200. On a non-loopback page the controls are disabled with a read-only status.
  3. Stats readout: opening the card (plus a manual Refresh) invokes one Remote method on the dangerGuardStats namespace. It is read on demand and never subscribed - verdict stats only move while the agent works, and a value cached across a host reconnect is exactly the staleness this design avoids.

What it does not provide

Worth knowing before you look for them:

  • It registers no tools. There is no guard_status or similar model tool - the whole surface is the ctx.tools.guard predicate plus the two preflight waterfall subscriptions.
  • It exposes no HTTP endpoint of its own. The single Remote namespace it registers, dangerGuardStats, exists only to feed the settings card's readout through the host's /api gateway (a no-argument, JSON-result method). There are no other routes, no /api/dangerGuard… surface to call from outside, and no CSRF or trust gate in this package - the host's gateway owns all of that.
  • It adds no CLI subcommand. dsh danger-guard … does not exist.
  • It keeps no file on disk and makes no network call (see Data and privacy for the single read-only subprocess).
  • What it *does* consume optionally: the lessonLoop bus (report/pass), sessionProjections for the tool ledger, fs for preflight evidence, and workingDirectory for the current directory base. Every one of those is an optional child injection - a profile without them keeps blocking, it just falls back.

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 patch carries two lines (bulkhead): - id: danger-guard (the guard half, no `Config`, name points at the package root) and - id: danger-guard-settings (the settings half, strict schema, name points at the ./settings subpath).
  • Only the first line brings up the card: the host scans dsh.client on bare package-name entries only, so the settings line's subpath name is deliberate rather than an oversight.
  • The card loads on the web platform only, as "danger-guard danger block" in the settings plugin list.
  • Deployment defaults sit on the settings row's config:; precedence is settings-card runtime value > row config > the built-in base (BUILTIN_BASE in lib/settings-schema.ts).
  • 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 on the guard row are inert by design: that entry takes no config argument at all.
  • Failures go the conservative way. Only the settings row carries the strict schema, so one broken user-layer config line fails that entry alone; the guard entry falls back to the built-in base and warns once. apply throws only when the settings surface itself is missing. A missing tools service does not throw (the registration is optional-chained), but tools is in inject, so cordis never activates the entry and the startup diagnostic names it - "no gate mounted, and the host says so".

What the rules block

Family · Blocks · Passes / way out Git hook bypass · --no-verify and its unambiguous abbreviations on commit / push / merge / cherry-pick / rebase / am; the short -n on commit / am; git -c core.hooksPath= and git config core.hooksPath (including --global and the = form). Git arguments are walked by role: short clusters expand letter by letter, a value-taking option's argument is never read as a flag, everything after -- is a pathspec, and -c's value is what gets checked · git push -n (dry run), git cherry-pick -n (no commit), git rebase -n (no stat), git config --get / --unset / --list core.hooksPath, and that text appearing inside a commit message Catastrophic rm · The threshold is per command-word track. On the POSIX/cmd track, recursion and force are required, the one exception being an upper-case -R anywhere in a short cluster, which is disaster-level on its own. On the Remove-Item / ri track (and, under the Windows dialect, its rm / del / rd aliases), any Recurse form alone against a disaster target is enough - no -Force required, because that form silently deletes a whole tree. Targets that count: /, ~ and ~/…, the literal home path, $HOME and every ${HOME…} form, . / .. / ../…, whole trees under system and user directory prefixes, and absolute globs / brace expansions, with the path lexically normalized first. The command-word side covers pwsh / cmd delete forms as well, matched case-insensitively · Relative cleanup inside the project (rm -rf node_modules, ./dist), force-only (rm -f ~) or lower-case recursion-only (rm -r ~) on the POSIX track - this slot is keyed by spelling, and the asymmetry is deliberate; Remove-Item -Recurse ./build; /var and /tmp (deliberately excluded: a false block there costs more than a miss); a different segment such as /usrx Download-and-run · A shell or script interpreter after curl / wget / fetch, including through sudo, a path-prefixed interpreter, xargs, or parallel, and the reversed process substitutions (sh / checkout . / checkout -f / checkout , restore (--staged alone is not judged - it only moves the index), clean -f* (including -xf / -x -f), stash drop / clear, branch -D / --delete --force, push -f / --force / --force-with-lease / +refspec. Short-option values are parsed per subcommand (push -u and clean -x are boolean, `commit -