toby-bridges/api-relay-audit
toby-bridges/api-relay-audit从 DeepSeek Harness 对 AI API 中转站和 LLM 代理运行本地安全审计,生成 Markdown 报告,覆盖提示词注入、模型替换信号、工具调用改写、错误泄漏、流完整性和按 profile 启用的 Web3 风险。
为 DSH 的每一次提权审批弹窗加一份由模型生成的注释:这段代码做什么、模型想达成什么、风险多大以及原因、每个选项意味着什么;并提供 /dsh-approval/history 历史授权查询(可搜索、可筛选、记录当时选了什么)与 /dsh-approval/diag 诊断页。
English | 中文
An AI annotator for the approval dialog in DeepSeek Harness (dsh).
When the model asks to run something outside your workspace, dsh shows an approval prompt. This plugin adds an explanation next to the command, generated from the model's own reasoning: what the code does, what the model is trying to achieve, how risky it is and why, and what each button actually means.
dsh plugin --profile desktop add github:ArisuYuuko/dsh-approval-explainer
desktop is the profile the desktop app boots. The first use of a profile initializes it.
Then fully quit and reopen DSH. Reloading the page is not enough: the host half loads only at process start.
Verify the layer was installed:
dsh --profile desktop --dump-config
# == dsh-approval-explainer
- id: dsh-approval-explainer
name: dsh-approval-explainer
inject:
- llm
- approval
This plugin is plain JavaScript with no build step, so pnpm does not ask you to authorize allowBuilds. See Install notes.
To remove it, along with its configuration layer:
dsh plugin --profile desktop remove dsh-approval-explainer
Without the plugin an approval prompt shows only the command:
即将执行
& '<temp>\diagnose-windows-sandbox-acl.ps1' -Path 'D:\some-project' -AllowRewrite
You have to work out what it does, what it touches, and what happens if you accept. If you cannot, you are choosing between trusting blindly and refusing blindly.
The plugin adds this to the same prompt:
Risk = medium (confidence high)
[What this code does]
Writes ~300 words of acceptance notes to <output dir>\note.txt outside the
workspace, using danger-full-access (cwd is <project dir>). The text itself
only describes a timeout fix and its measurements.
[What the model is trying to achieve]
You explicitly asked it to file an escalation request, so the model
deliberately writes outside the workspace to trigger a real approval prompt
and measure annotation latency.
[Why this risk level]
The content is harmless and this is not production, but it explicitly asks
for danger-full-access and targets a path outside the workspace. That falls
under permission and security controls, so the standard rating is medium.
[Recommendation]
Approve once: the content is harmless and matches your instruction, but let
it through only this time rather than granting standing full-disk access.
[What each option means]
Approve once -> the text is really written to <output dir>\note.txt and stays
on disk until you delete it; other writes under the same grant
are also allowed.
Deny -> nothing is written and the latency check cannot complete; the
model is told it was denied and may retry inside the workspace
or stop and ask you.
Note the last block. It does not just say "the command will run" — it says the file will really be created, that it can be deleted, and that other writes are covered by the same grant. That is the information a decision needs.
| Only real approvals | No annotations for routine tool calls. In one measurement approval/request fired 2 times while tools/pre-execute fired 259 — annotating everything is the same as annotating nothing |
| Never blocks approval | Generation runs in the background; the prompt appears immediately. A failure or timeout cannot affect what you may approve |
| Failures are visible | When generation fails the panel shows why instead of hanging silently |
| Arguments come from the session | Tool arguments are looked up by callId in the session log, with three fallbacks. If they cannot be found the annotation says so instead of inventing them |
| Prompt-injection resistant | The system prompt states that code, paths, and commands are data, not instructions |
| Does not change policy | It observes the approval waterfall; it does not decide who may be authorized |
The plugin is a DSH bundle: an npm package carrying a cordis.patch.yml that contributes one configuration layer to a profile.
package.json declares dsh.bundle.patch, pointing at the layer file
cordis.patch.yml the layer: one insert row referencing the package by name
lib/index.js host half (Node)
lib/client.js browser half (Web)
The whole install mechanism is five lines of YAML:
- insert:
- id: dsh-approval-explainer
name: dsh-approval-explainer # by package name, so Node resolution finds the installed code
inject:
- llm
- approval
dsh plugin add installs the package into the profile and, because the package declares dsh.bundle, appends it to dsh.profile.bundles. The effective config gains one layer.
lib/index.js host half (Node)
observes the approval/request waterfall, generates annotations in
the background via the LLM, caches them, serves 8 HTTP routes
lib/client.js browser half (Web)
registers into the conversation.approval.detail slot and renders
the command plus the annotation card
Only approval/request is hooked. It is the only precise signal for "the user is being asked to decide something right now". An earlier version also hooked tools/pre-execute to pre-generate; the measured signal-to-noise was 259:2, where 259 routine operations drowned the 2 that mattered.
Approval is never blocked. The listener starts background generation and immediately return next(), handing the decision back to the real policy listener. So a successful, failed, or timed-out generation cannot affect what you may approve.
Arguments are looked up in the session log. The approval/request event carries no tool arguments (only toolName, callId, reason), so the plugin finds the tool/call event by callId with three fallbacks (surface.nodes → snapshotEvents() → events). When it cannot, the annotation says arguments are missing rather than inventing them.
Shadowing the official renderer requires an explicit priority. conversation.approval.detail is a single slot, and the official dsh-client-ui-chat already occupies priority: 0. Two registrations at the same priority in one slot is an error, and entriesOfSlot takes the first entry in ascending priority order — so shadowing requires a lower number (-1). The cost is having to re-render the command line yourself.
1. Never inject a client bundle yourself. dsh-client-modules deduplicates by URL but rejects duplicate registrations by module id. If the host injects another <script src=".../client.js">, the browser half executes twice, web boot fails, and DSH runs its crash self-heal — which removes profile config and disables every third-party plugin. The startup graph loads the browser half; the host only serves routes.
2. apply() must never throw. Throwing marks the host fiber FAILED, the whole composition fails to mount, and the symptom is "DSH will not start after installing this". Every step in apply() is individually wrapped; any failure is recorded and execution continues.
Every real escalation is recorded so you can look back at it later:
http://127.0.0.1:<dsh-web-port>/dsh-approval/history
No URL to remember (v0.9.0). The browser half adds an Approval history button to the Session header action row — the same row as the Conversation / Trajectory tabs (slot conversation.session.header.actions). Clicking it opens the page above in a new tab. The button is registered as a list entry with its own id and no priority, so it sits beside the other plugins that share that row (agent-preset, job-list, schedule-catalog, agent-team) without displacing any of them.
A floating panel inside DSH, with the full page in your browser (v0.9.3 → v0.9.4). Clicking the button no longer navigates anywhere: it fetches /dsh-approval/history?raw=1&limit=200 and renders a panel above the conversation — the same interaction as the plugin's health-check panel. The panel carries a header (record count, refresh, close), filters (keyword / decision / risk, applied locally), one collapsible row per approval (time, tool, risk and decision badges, session), and a footer whose “full page” button opens the page in the default browser (v0.9.4) for advanced operations such as clearing the history. Earlier versions had to learn this the hard way: the desktop shell serves the UI under dsh-app://app/ and its main process only hands http(s) targets to the system browser, so a relative new-window URL was denied silently (v0.9.0: the button did nothing); prefixing the host origin fixed the click but sent the page to the system browser (v0.9.1), which was not the point. window.open and target="_blank" are gone from the client half.
The plugin stores only its own annotations (<profile>/data/dsh-approval-explainer/annotations.jsonl). The decision — allowed / rejected / cancelled — is read from DSH's own session log, where it already lives as paired approval/asked + approval/decided events. The two are joined by callId.
This split is deliberate: the host is the authority on who decided what, so duplicating it would only create a second version of the truth that drifts.
| Query | Meaning |
|---|---|
| (none) | HTML page |
?raw=1 | JSON |
?q= | Keyword: tool / path / annotation / reason |
?decision=allow | allow / deny / cancel / pending |
?risk=high | low / medium / high |
?session= | One session only |
?since= / ?until= | ISO time range |
?limit= | Default 100, max 1000 |
?refresh=1 | Bypass the revision cache |
POST /dsh-approval/history/clear empties the annotation file. It never touches DSH's session log.
If DSH doesn't expose sessionPersistence (older host), the page still opens and says so — annotations remain readable, only the decision column is unavailable.
The page is server-rendered with zero client dependencies. This is a diagnostic page: its whole reason to exist is to still open when something is broken, so it must be sturdier than the thing it diagnoses. It doesn't need the browser half, a fetch call, or React.
http://127.0.0.1:<dsh-web-port>/dsh-approval/diag
<dsh-web-port> is the port the DSH web UI is currently serving on — it is chosen at runtime and changes across restarts, so don't hardcode it. Look at the URL of the DSH window itself.
A check page. Each item carries a conclusion and an action; raw data is folded at the end. Three output formats:
| Query | Output |
|---|---|
| (none) | HTML page for humans |
?raw=1 | Full JSON for scripts |
?format=text | Plain text for curl |
The host also renders an annotation list at /dsh-approval/view without going through the browser half, so annotations remain inspectable even if the browser half misbehaves.
Faster entry — no URL to type (v0.8.0). The browser half registers a 🩺 Plugin check button at the sidebar foot (slot sidebar.footer.action). It reads /dsh-approval/diag?raw=1 and lays the same conclusions out in a panel: verdict pill, checks, counters, browser-half status, crash and self-heal evidence, profile state, related-page links, and the raw JSON with a copy button. The panel refreshes every 10 s while open; ↻ re-checks immediately and ↗ opens the full page above. The version it shows is the one the running host half reports, so after bumping the package you must restart DSH before that number changes.
No annotation in the prompt. Check /dsh-approval/client. registered: false means the browser half never entered the startup graph; all three conditions must hold — the package is in the startup graph, package.json declares dsh.client.platform === "web", and it exports "./client".
"Annotation could not be generated." The panel footer states the reason: annotation generation timed out (40 s limit), no model route available, or arguments missing.
Changes do not take effect. lib/index.js is the host half — restart DSH. lib/client.js is the browser half — reloading the page suffices.
To tell which revision a running process loaded, compare the moduleFingerprint field of /dsh-approval/diag?raw=1 with the sha256 of the file on disk. Do not use file mtime — any unrelated write moves it forward and the comparison silently flips.
Not configurable through cordis.yml today. The tunables are constants at the top of lib/index.js, each with a measured rationale:
| Constant | Default | Purpose |
|---|---|---|
GENERATE_TIMEOUT_MS | 40000 | Generation deadline. Measured successes take 5–9 s |
ENTRY_TTL_MS | 600000 | Annotation cache lifetime |
MAX_ARGS_CHARS | 12000 | Tool-argument budget fed to the model |
MAX_REASONING_CHARS | 4000 | Model reasoning budget |
MAX_ASSISTANT_TEXT_CHARS | 2000 | Model answer budget |
MAX_ENTRIES | 200 | Cache size cap |
The DSH documentation states a convention in Plugin configuration:
No hardcoded tunables — any value a deployment might need to change must be a config field. Test: can you change it in
cordis.ymlwithout editing code?
By that test all six should be exported as a Schemastery Config schema. They are not. This is a known deviation, and the reasons are measured rather than assumed:
apply. Exporting Config needs import Schema from '@deepseek-ai/schemastery', and that package does not resolve from the plugin's own directory (measured: Cannot find module). Only the host runtime's peer resolution can supply it.apply, so the plugin's try/catch cannot cover it. A resolution failure is a module load failure and marks the fiber FAILED — the exact failure class behind this plugin's early crashes.Config; the other five — including a mature 190 KB plugin — do not.The practical impact is limited: all six defaults are measured and work unchanged. Editing the constants and restarting covers the need.
lib/client.js polls POLL_LIMIT (120) × POLL_INTERVAL_MS (350 ms). That window must be ≥ GENERATE_TIMEOUT_MS, or the host is still generating while the panel has given up — the annotation is produced and thrown away while the user sees a failure.
The nominal window is 42 s, but the loop requests before it sleeps, so the last request lands at 119 × 350 ms ≈ 41.65 s. Change both values together.
Trust fence. Seven of the eight routes pass through a fence with three checks:
Host header names a loopback address (localhost, *.localhost, ::1, 127.0.0.0/8)sec-fetch-site is not cross-siteOrigin is either dsh-app: or the same host as HostIt does not check req.socket.remoteAddress — the connection's source address is never inspected. This is not currently exploitable: DSH's web server binds 127.0.0.1 only, so no external source can reach the port, and a same-machine process already has local privileges and has no fence to bypass. If the server ever binds 0.0.0.0, this gap becomes real and source checking must be added.
Annotation text is untrusted. It comes from the model and is HTML-escaped on render. The system prompt also tells the model that code and commands in the material are data, not instructions, to limit prompt injection.
Measured by the author under an isolated DSH_HOME, so no real profile was touched:
$ dsh plugin --profile gh add github:ArisuYuuko/dsh-approval-explainer
+ dsh-approval-explainer github:ArisuYuuko/dsh-approval-explainer
Packages: +1
Done in 4.4s using pnpm v11.7.0
$ dsh --profile gh --dump-config
# == dsh-approval-explainer
- id: dsh-approval-explainer
...
The resulting pnpm-workspace.yaml contained no allowBuilds entry, confirming no manual authorization was required, and the seven installed files matched the repository byte for byte.
The packaging and installing plugins guide notes that a git install fetches source, not build output, so TypeScript packages need a prepare script plus user authorization. This plugin ships plain JavaScript in lib/, so neither applies. If you are asked for allowBuilds during install, please open an issue — it would mean that reasoning is wrong.
Local paths and tarballs work through the same command, which is useful during development:
dsh plugin --profile desktop add ./dsh-approval-explainer
package.json declares the host range this plugin was built and tested against:
"engines": { "dsh": ">=0.2.0-rc.1 <0.3.0-0" }
The market shows this on the card, so a host that falls outside the range is flagged before install rather than after. It is a statement about what has been tested, not a guarantee that nothing else works.
Upgrading DSH is the one event most likely to break a plugin, because this plugin talks to host internals (approval/request, ctx.llm.stream, the webServer service, the conversation.approval.detail slot). After an upgrade, check /dsh-approval/diag first — it reports whether the host half loaded, whether the browser half entered the startup graph, and whether the profile config is intact.
lib/index.js host half
lib/client.js browser half
cordis.patch.yml the bundle layer
package.json package metadata and DSH declarations
This plugin was built alongside a suite of checks, most of which do not need DSH running:
node verify-evaluate.mjs # real ESM evaluation — catches reference errors node --check misses
node verify-http.mjs # drives a real HTTP server through all 8 routes
node verify-semantics.mjs # dead code, contract consistency, risky patterns
node verify-client-boot.mjs # browser-half load contract
Suites using vm.SourceTextModule need --experimental-vm-modules.
Prefer real evaluation over text matching. This plugin was once broken by calling fileURLToPath(import.meta.url) when the identifier was never imported: the call always threw ReferenceError, an outer catch swallowed it, and the function returned null forever. The text assertion source.includes('fileURLToPath(...)') passed, because those characters were in the file. A text assertion and a working function are separated by an entire scope resolution.