toby-bridges/api-relay-audit
toby-bridges/api-relay-audit从 DeepSeek Harness 对 AI API 中转站和 LLM 代理运行本地安全审计,生成 Markdown 报告,覆盖提示词注入、模型替换信号、工具调用改写、错误泄漏、流完整性和按 profile 启用的 Web3 风险。
执行前拦截:`tools.guard` 谓词按当前权限档位,对危险 shell 命令(跳过 git 钩子、灾难性 `rm`、下载即执行、重写 git 历史)与「写凭据路径」「没读过目标文件的首次编辑」逐条给出放行 / 询问 / 拒绝,并把成因与可行替代写法交回模型。
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.
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.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.secretPathDeny) - matched by argument key shape, never by tool name, because tool names drift with the provider.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.)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.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).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).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.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.agent.inject(): that API queues context for the *next* pre-step, while decisions are made per step - evidence would arrive *after* the edit landed.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.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.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.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.lib/command-segments.ts). Two different machines would give the same string opposite verdicts in different predicates.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.-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.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.dsh plugin --profile web add @jayyuen66/dsh-danger-guardRemove 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.
require() of an ESM entry work at all, which is why engines.node is declared.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.@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.@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).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.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).prepack rebuilds them, so what ships is always what the current source produces.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.
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.# == or # == , patched by ; any check must accept the second form or it will report "not installed" about a layer that is really there.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.Save becomes enabled, and the write returns 200. On a non-loopback page the controls are disabled with a read-only status.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.Worth knowing before you look for them:
guard_status or similar model tool - the whole surface is the ctx.tools.guard predicate plus the two preflight waterfall subscriptions.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.dsh danger-guard … does not exist.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.dsh.bundle.patch in package.json points at cordis.patch.yml, and dsh plugin add/remove maintains the assembly layer.- 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).dsh.client on bare package-name entries only, so the settings line's subpath name is deliberate rather than an oversight.config:; precedence is settings-card runtime value > row config > the built-in base (BUILTIN_BASE in lib/settings-schema.ts).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.config argument at all.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".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 -