dsh-agy-link 0.4.75 → 0.4.77
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +34 -0
- package/dist/index.js +61 -10
- package/docs/KNOWN-GAPS.md +25 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,39 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.77 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
### English
|
|
6
|
+
|
|
7
|
+
- **Security(mcp): harden the loopback endpoint against a token-holding, prompt-injected child.**
|
|
8
|
+
- **Bounded request body** — `/call` now caps the buffered body at 8 MiB and returns **413 `request body too large`** instead of buffering an unbounded payload, so a compromised/injected agy child cannot OOM the host process by streaming huge tool arguments.
|
|
9
|
+
- **Constant-time bearer check** — the shared-secret token is compared with `crypto.timingSafeEqual` (length-guarded) rather than `!==`, removing the timing side-channel.
|
|
10
|
+
|
|
11
|
+
### 中文
|
|
12
|
+
|
|
13
|
+
- **安全(MCP):加固 loopback 端点,防御持有 token、被提示词注入的子进程。**
|
|
14
|
+
- **请求体上限**:`/call` 现在把缓冲上限设为 8 MiB,超限直接返回 **413 `request body too large`**,避免被攻陷/注入的 agy 子进程用超大工具参数把宿主进程内存打爆。
|
|
15
|
+
- **常量时间 token 校验**:共享密钥改用 `crypto.timingSafeEqual`(先校验长度)比对,消除 `!==` 的时序侧信道。
|
|
16
|
+
|
|
17
|
+
**Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.76...v0.4.77
|
|
18
|
+
|
|
19
|
+
## 0.4.76 (2026-10-29)
|
|
20
|
+
|
|
21
|
+
### English
|
|
22
|
+
|
|
23
|
+
- **Security(mcp): enforce `mcpToolAllowlist` at execution time, not just on `/tools`.**
|
|
24
|
+
- `GET /tools` filtered the served set by `mcpToolAllowlist`, but `POST /call` accepted any tool name (except the internal `run_code`/`agy_ask`). The agy child holds the bridge bearer token, so a prompt-injected or compromised child could `POST /call` for a tool the operator never exposed (e.g. `bash`, `write`) and bypass the allowlist entirely. `/call` now returns **403 `tool not allowed`** for any tool outside a non-empty allowlist, and audit-logs it as `mcp call <tool> [denied: not in allowlist]`. Empty allowlist still means "all non-internal tools" (unchanged).
|
|
25
|
+
- **Security(mcp): stop exposing the internal `agy_tool` mirror over the bridge.**
|
|
26
|
+
- The internal-tool filter only excluded `run_code`/`agy_ask`, so the activity-mirror tool `agy_tool` (which replays recorded agy tool activity back into DSH) was listed by `/tools` and executable via `/call` — letting the external agy child re-enter the bridge. The exclusion set is now centralized (`INTERNAL_TOOL_NAMES = run_code, agy_ask, agy_tool`) and enforced identically when listing and when executing, so the two can never drift.
|
|
27
|
+
|
|
28
|
+
### 中文
|
|
29
|
+
|
|
30
|
+
- **安全(MCP):`mcpToolAllowlist` 现在在「执行时」强制生效,而不只是过滤 `/tools` 列表。**
|
|
31
|
+
- 此前 `/tools` 按 allowlist 过滤,但 `POST /call` 只拦内部 `run_code`/`agy_ask`,任意工具名都会被真正执行。agy 子进程本身持有桥接 token,因此被提示词注入或攻陷的子进程可以 `POST /call` 调用运维并未暴露的工具(如 `bash`、`write`),完全绕过 allowlist。现在非空 allowlist 之外的 `/call` 一律返回 **403 `tool not allowed`**,并审计为 `mcp call <tool> [denied: not in allowlist]`。空 allowlist 仍表示「全部非内部工具」(行为不变)。
|
|
32
|
+
- **安全(MCP):不再把内部的 `agy_tool` 镜像工具暴露给桥接。**
|
|
33
|
+
- 内部工具过滤此前只排除 `run_code`/`agy_ask`,导致「活动镜像」工具 `agy_tool`(把 agy 记录的工具活动回放到 DSH)会被 `/tools` 列出、并可经 `/call` 执行,等于让外部 agy 子进程重新进入桥接。现将内部工具集合集中为 `INTERNAL_TOOL_NAMES = run_code, agy_ask, agy_tool`,在「列出」与「执行」两处统一生效,避免两处漂移。
|
|
34
|
+
|
|
35
|
+
**Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.75...v0.4.76
|
|
36
|
+
|
|
3
37
|
## 0.4.75 (2026-10-29)
|
|
4
38
|
|
|
5
39
|
### English
|
package/dist/index.js
CHANGED
|
@@ -6,7 +6,7 @@ import { homedir, tmpdir } from "node:os";
|
|
|
6
6
|
import { mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises";
|
|
7
7
|
import * as dshLlm from "@deepseek-ai/dsh-llm";
|
|
8
8
|
import { LlmAdapter, LlmError } from "@deepseek-ai/dsh-llm";
|
|
9
|
-
import { createHash, randomBytes, randomUUID } from "node:crypto";
|
|
9
|
+
import { createHash, randomBytes, randomUUID, timingSafeEqual } from "node:crypto";
|
|
10
10
|
import { defineTool } from "@deepseek-ai/dsh-tools";
|
|
11
11
|
import { EventEmitter } from "node:events";
|
|
12
12
|
import { createServer } from "node:http";
|
|
@@ -34642,18 +34642,59 @@ var WarmupService = class {
|
|
|
34642
34642
|
//#endregion
|
|
34643
34643
|
//#region src/host/mcp-bridge.ts
|
|
34644
34644
|
const MCP_SERVER_KEY = "dsh-tools";
|
|
34645
|
+
/**
|
|
34646
|
+
* Tools that must NEVER be exposed to an external MCP client. These are
|
|
34647
|
+
* internal transports / loopback-only helpers of the plugin itself:
|
|
34648
|
+
* - `run_code` — the deployment's model-only wrapper around code execution.
|
|
34649
|
+
* - `agy_ask` — the plugin's own ask tool (would recurse into agy).
|
|
34650
|
+
* - `agy_tool` — replays recorded agy tool activity back into DSH; calling it
|
|
34651
|
+
* over the bridge would let the agy child re-enter the bridge.
|
|
34652
|
+
* Kept in one place so the /tools listing and the /call guard can never drift.
|
|
34653
|
+
*/
|
|
34654
|
+
const INTERNAL_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
34655
|
+
"run_code",
|
|
34656
|
+
"agy_ask",
|
|
34657
|
+
"agy_tool"
|
|
34658
|
+
]);
|
|
34645
34659
|
/** MCP tool names are [a-zA-Z0-9_-]; DSH names may contain dots. */
|
|
34646
34660
|
function toMcpName(name) {
|
|
34647
34661
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
34648
34662
|
}
|
|
34649
|
-
|
|
34663
|
+
/** Loopback request-body cap. Tool arguments are tiny; this only exists so a
|
|
34664
|
+
* token-holding (possibly prompt-injected) agy child cannot OOM the host by
|
|
34665
|
+
* streaming an unbounded body at /call. */
|
|
34666
|
+
const DEFAULT_MAX_BODY_BYTES = 8388608;
|
|
34667
|
+
function readBody(req, maxBytes) {
|
|
34650
34668
|
return new Promise((resolve) => {
|
|
34651
34669
|
const chunks = [];
|
|
34652
|
-
|
|
34653
|
-
|
|
34654
|
-
req.on("
|
|
34670
|
+
let size = 0;
|
|
34671
|
+
let tooLarge = false;
|
|
34672
|
+
req.on("data", (c) => {
|
|
34673
|
+
size += c.length;
|
|
34674
|
+
if (size > maxBytes) {
|
|
34675
|
+
tooLarge = true;
|
|
34676
|
+
chunks.length = 0;
|
|
34677
|
+
return;
|
|
34678
|
+
}
|
|
34679
|
+
chunks.push(c);
|
|
34680
|
+
});
|
|
34681
|
+
req.on("end", () => resolve({
|
|
34682
|
+
tooLarge,
|
|
34683
|
+
text: tooLarge ? "" : Buffer.concat(chunks).toString("utf8")
|
|
34684
|
+
}));
|
|
34685
|
+
req.on("error", () => resolve({
|
|
34686
|
+
tooLarge,
|
|
34687
|
+
text: ""
|
|
34688
|
+
}));
|
|
34655
34689
|
});
|
|
34656
34690
|
}
|
|
34691
|
+
/** Constant-time bearer comparison (the token is a shared secret). */
|
|
34692
|
+
function bearerMatches(provided, expected) {
|
|
34693
|
+
const a = Buffer.from(provided, "utf8");
|
|
34694
|
+
const b = Buffer.from(expected, "utf8");
|
|
34695
|
+
if (a.length !== b.length) return false;
|
|
34696
|
+
return timingSafeEqual(a, b);
|
|
34697
|
+
}
|
|
34657
34698
|
function sendJson(res, status, body) {
|
|
34658
34699
|
const text = JSON.stringify(body);
|
|
34659
34700
|
res.writeHead(status, {
|
|
@@ -34692,7 +34733,7 @@ function startMcpBridge(opts) {
|
|
|
34692
34733
|
const server = createServer((req, res) => {
|
|
34693
34734
|
(async () => {
|
|
34694
34735
|
const url = (req.url ?? "").split("?")[0];
|
|
34695
|
-
if (String(req.headers["authorization"] ?? "")
|
|
34736
|
+
if (!bearerMatches(String(req.headers["authorization"] ?? ""), "Bearer " + token)) {
|
|
34696
34737
|
sendJson(res, 401, { error: "unauthorized" });
|
|
34697
34738
|
return;
|
|
34698
34739
|
}
|
|
@@ -34706,7 +34747,7 @@ function startMcpBridge(opts) {
|
|
|
34706
34747
|
const allowSet = new Set(allow);
|
|
34707
34748
|
const seen = /* @__PURE__ */ new Set();
|
|
34708
34749
|
sendJson(res, 200, { tools: svc.schemas().filter((t) => allow.length === 0 || allowSet.has(t.name)).filter((t) => {
|
|
34709
|
-
if (
|
|
34750
|
+
if (INTERNAL_TOOL_NAMES.has(t.name)) return false;
|
|
34710
34751
|
const mapped = toMcpName(t.name);
|
|
34711
34752
|
if (seen.has(mapped)) return false;
|
|
34712
34753
|
seen.add(mapped);
|
|
@@ -34723,10 +34764,14 @@ function startMcpBridge(opts) {
|
|
|
34723
34764
|
return;
|
|
34724
34765
|
}
|
|
34725
34766
|
if (req.method === "POST" && (url === "/call" || url === "/mcp/call")) {
|
|
34726
|
-
const body = await readBody(req);
|
|
34767
|
+
const body = await readBody(req, opts.maxBodyBytes ?? DEFAULT_MAX_BODY_BYTES);
|
|
34768
|
+
if (body.tooLarge) {
|
|
34769
|
+
sendJson(res, 413, { error: "request body too large" });
|
|
34770
|
+
return;
|
|
34771
|
+
}
|
|
34727
34772
|
let parsed = {};
|
|
34728
34773
|
try {
|
|
34729
|
-
parsed = JSON.parse(body);
|
|
34774
|
+
parsed = JSON.parse(body.text);
|
|
34730
34775
|
} catch {
|
|
34731
34776
|
sendJson(res, 400, { error: "invalid JSON" });
|
|
34732
34777
|
return;
|
|
@@ -34737,7 +34782,7 @@ function startMcpBridge(opts) {
|
|
|
34737
34782
|
return;
|
|
34738
34783
|
}
|
|
34739
34784
|
const dshName = typeof parsed.dshName === "string" ? parsed.dshName : "";
|
|
34740
|
-
if (dshName === "" || dshName
|
|
34785
|
+
if (dshName === "" || INTERNAL_TOOL_NAMES.has(dshName)) {
|
|
34741
34786
|
sendJson(res, 400, { error: "bad tool name" });
|
|
34742
34787
|
return;
|
|
34743
34788
|
}
|
|
@@ -34748,6 +34793,12 @@ function startMcpBridge(opts) {
|
|
|
34748
34793
|
opts.log?.("mcp call " + dshName + " [" + outcome + "] " + (Date.now() - startedAt) + "ms");
|
|
34749
34794
|
} catch {}
|
|
34750
34795
|
};
|
|
34796
|
+
const allow = opts.allowlist().split(",").map((s) => s.trim()).filter(Boolean);
|
|
34797
|
+
if (allow.length > 0 && !allow.includes(dshName)) {
|
|
34798
|
+
audit("denied: not in allowlist");
|
|
34799
|
+
sendJson(res, 403, { error: "tool not allowed" });
|
|
34800
|
+
return;
|
|
34801
|
+
}
|
|
34751
34802
|
try {
|
|
34752
34803
|
const sessionHeader = String(req.headers["x-dsh-session"] ?? "");
|
|
34753
34804
|
const resolveSession = async (id) => id === "" ? void 0 : await Promise.resolve(opts.resolveAgent?.(id));
|
package/docs/KNOWN-GAPS.md
CHANGED
|
@@ -110,11 +110,34 @@ reference-counted slot is the strongest isolation the plugin can provide.
|
|
|
110
110
|
server in the MCP registry (`~/.gemini/config/mcp_config.json` by default,
|
|
111
111
|
where `agy` 1.4+ actually reads it, merged in and restored on disable).
|
|
112
112
|
Bridged calls carry caller identity via `DSH_MCP_SESSION` so identity-enforcing
|
|
113
|
-
tools (such as `agent_teams`) function correctly.
|
|
114
|
-
|
|
113
|
+
tools (such as `agent_teams`) function correctly. Internal loopback-only
|
|
114
|
+
helpers — `run_code`, `agy_ask`, and the activity-mirror `agy_tool` — are
|
|
115
|
+
never bridged (excluded from both `/tools` and `/call`); `mcpToolAllowlist`
|
|
116
|
+
restricts the served set **and is
|
|
117
|
+
enforced at execution time** — a `/call` for a tool outside the allowlist is
|
|
118
|
+
refused (403) even when the caller holds the bridge token, so a prompt-injected
|
|
119
|
+
agy child cannot silently reach tools the operator did not expose. The
|
|
120
|
+
loopback endpoint also caps request bodies (8 MiB → HTTP 413) and compares the
|
|
121
|
+
bearer token in constant time, so a token-holding but prompt-injected child
|
|
122
|
+
cannot OOM the host with an unbounded body or probe the token via timing.
|
|
115
123
|
Every bridged call is audit-logged as `mcp call <tool> [<outcome>] <ms>` with
|
|
116
124
|
the caller class and the failure text (never the arguments), so a failed run
|
|
117
125
|
is visible in journald instead of only in transcripts.
|
|
126
|
+
|
|
127
|
+
**Contract for external / plugin callers of `tools.execute(exec)` (see #61):**
|
|
128
|
+
DSH's `ToolExecutionInput` requires `callId`, `name`, `arguments` **and
|
|
129
|
+
`signal: AbortSignal`** — a call without `signal` throws
|
|
130
|
+
`Cannot read properties of undefined (reading 'aborted')` inside any tool
|
|
131
|
+
that awaits. Omit `agent` entirely when there is no real session (leave it
|
|
132
|
+
`undefined`, **never** `null`: DSH checks `exec.agent === void 0`, so `null`
|
|
133
|
+
takes the else branch and reads `null.session`). This plugin's host-side
|
|
134
|
+
`/call` handler already does both (it mints an `AbortController` per call and
|
|
135
|
+
resolves the caller from the `x-dsh-session` header, passing `agent` only when
|
|
136
|
+
one is found). A fabricated `agent.session` cannot pass DSH's live-session
|
|
137
|
+
store check — there is no supported way to fake a real Session from outside;
|
|
138
|
+
tools that require one need a real `agent` or they fail by design.
|
|
139
|
+
Documenting the full DSH-core `execute(exec)` contract and any official
|
|
140
|
+
sessionless-execution API is upstream work, not this bundle.
|
|
118
141
|
- **DSH `send_message` from an agy child** - DSH's sub-agent runtime appends a
|
|
119
142
|
footer telling the child to report with `send_message({ agent_id: <parent> })`.
|
|
120
143
|
In an agy run that name resolves to agy's **own** `send_message`, which does
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agy-link",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.77",
|
|
4
4
|
"description": "Google Antigravity (agy CLI) models for DeepSeek Harness — stream Gemini/Claude/GPT-OSS subscriptions into DSH with thinking, tool activity, token usage and in-GUI Google OAuth login.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|