dsh-agy-link 0.4.76 → 0.4.78
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 +30 -0
- package/dist/index.js +75 -28
- package/docs/KNOWN-GAPS.md +4 -1
- package/docs/SECURITY-NOTES.md +12 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,35 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.78 (2026-10-29)
|
|
4
|
+
|
|
5
|
+
### English
|
|
6
|
+
|
|
7
|
+
- **Refactor(mcp/http): one bounded JSON body reader for every HTTP surface.**
|
|
8
|
+
- Extracted `src/host/read-json-body.ts` and routed both the MCP bridge `/call` and all `/plugins/agy-link/*` web routes through it. Plugin payloads are tiny config JSON; the old unbounded buffer let any local client (including a prompt-injected agy child holding the bridge token) exhaust host memory by streaming a huge POST body. Bodies are now capped (1 MiB for plugin routes, 8 MiB for `/call`) and oversized ones are discarded. Adds focused unit tests for the reader (valid/malformed/array/oversized/stream-error).
|
|
9
|
+
|
|
10
|
+
### 中文
|
|
11
|
+
|
|
12
|
+
- **重构(mcp/http):所有 HTTP 面共用同一个有界 JSON body 读取器。**
|
|
13
|
+
- 抽出 `src/host/read-json-body.ts`,MCP 桥接 `/call` 与全部 `/plugins/agy-link/*` Web 路由统一走它。插件请求体都是很小的配置 JSON,此前的无界缓冲会让任意本地客户端(包括持有桥接 token、被提示词注入的 agy 子进程)用超大 POST body 打爆宿主内存。现在缓冲有上限(插件路由 1 MiB、`/call` 8 MiB),超限直接丢弃。为该读取器补充了针对性单测(正常/非法/数组/超限/流错误)。
|
|
14
|
+
|
|
15
|
+
**Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.77...v0.4.78
|
|
16
|
+
|
|
17
|
+
## 0.4.77 (2026-10-29)
|
|
18
|
+
|
|
19
|
+
### English
|
|
20
|
+
|
|
21
|
+
- **Security(mcp): harden the loopback endpoint against a token-holding, prompt-injected child.**
|
|
22
|
+
- **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.
|
|
23
|
+
- **Constant-time bearer check** — the shared-secret token is compared with `crypto.timingSafeEqual` (length-guarded) rather than `!==`, removing the timing side-channel.
|
|
24
|
+
|
|
25
|
+
### 中文
|
|
26
|
+
|
|
27
|
+
- **安全(MCP):加固 loopback 端点,防御持有 token、被提示词注入的子进程。**
|
|
28
|
+
- **请求体上限**:`/call` 现在把缓冲上限设为 8 MiB,超限直接返回 **413 `request body too large`**,避免被攻陷/注入的 agy 子进程用超大工具参数把宿主进程内存打爆。
|
|
29
|
+
- **常量时间 token 校验**:共享密钥改用 `crypto.timingSafeEqual`(先校验长度)比对,消除 `!==` 的时序侧信道。
|
|
30
|
+
|
|
31
|
+
**Full Changelog**: https://github.com/amlyczz/dsh-agy-link/compare/v0.4.76...v0.4.77
|
|
32
|
+
|
|
3
33
|
## 0.4.76 (2026-10-29)
|
|
4
34
|
|
|
5
35
|
### 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";
|
|
@@ -34639,6 +34639,57 @@ var WarmupService = class {
|
|
|
34639
34639
|
return this.warmupAllAccounts({ force: false });
|
|
34640
34640
|
}
|
|
34641
34641
|
};
|
|
34642
|
+
function readJsonBody(req, opts = {}) {
|
|
34643
|
+
const maxBytes = opts.maxBytes ?? 1048576;
|
|
34644
|
+
const emitter = req;
|
|
34645
|
+
return new Promise((resolve) => {
|
|
34646
|
+
const chunks = [];
|
|
34647
|
+
let size = 0;
|
|
34648
|
+
let tooLarge = false;
|
|
34649
|
+
const done = (result) => resolve(result);
|
|
34650
|
+
emitter.on?.("data", (chunk) => {
|
|
34651
|
+
const buf = chunk ?? Buffer.alloc(0);
|
|
34652
|
+
size += buf.length;
|
|
34653
|
+
if (size > maxBytes) {
|
|
34654
|
+
tooLarge = true;
|
|
34655
|
+
chunks.length = 0;
|
|
34656
|
+
return;
|
|
34657
|
+
}
|
|
34658
|
+
chunks.push(buf);
|
|
34659
|
+
});
|
|
34660
|
+
emitter.on?.("end", () => {
|
|
34661
|
+
if (tooLarge) return done({
|
|
34662
|
+
value: {},
|
|
34663
|
+
tooLarge: true,
|
|
34664
|
+
invalid: false
|
|
34665
|
+
});
|
|
34666
|
+
try {
|
|
34667
|
+
const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
34668
|
+
if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) return done({
|
|
34669
|
+
value: parsed,
|
|
34670
|
+
tooLarge: false,
|
|
34671
|
+
invalid: false
|
|
34672
|
+
});
|
|
34673
|
+
return done({
|
|
34674
|
+
value: {},
|
|
34675
|
+
tooLarge: false,
|
|
34676
|
+
invalid: true
|
|
34677
|
+
});
|
|
34678
|
+
} catch {
|
|
34679
|
+
return done({
|
|
34680
|
+
value: {},
|
|
34681
|
+
tooLarge: false,
|
|
34682
|
+
invalid: true
|
|
34683
|
+
});
|
|
34684
|
+
}
|
|
34685
|
+
});
|
|
34686
|
+
emitter.on?.("error", () => done({
|
|
34687
|
+
value: {},
|
|
34688
|
+
tooLarge: false,
|
|
34689
|
+
invalid: true
|
|
34690
|
+
}));
|
|
34691
|
+
});
|
|
34692
|
+
}
|
|
34642
34693
|
//#endregion
|
|
34643
34694
|
//#region src/host/mcp-bridge.ts
|
|
34644
34695
|
const MCP_SERVER_KEY = "dsh-tools";
|
|
@@ -34660,13 +34711,17 @@ const INTERNAL_TOOL_NAMES = /* @__PURE__ */ new Set([
|
|
|
34660
34711
|
function toMcpName(name) {
|
|
34661
34712
|
return name.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
34662
34713
|
}
|
|
34663
|
-
|
|
34664
|
-
|
|
34665
|
-
|
|
34666
|
-
|
|
34667
|
-
|
|
34668
|
-
|
|
34669
|
-
|
|
34714
|
+
/** Loopback request-body cap for /call. Tool arguments are tiny; this only
|
|
34715
|
+
* exists so a token-holding (possibly prompt-injected) agy child cannot OOM the
|
|
34716
|
+
* host by streaming an unbounded body. The bounded reader itself is shared with
|
|
34717
|
+
* the plugin's web routes (see ./read-json-body.ts). */
|
|
34718
|
+
const MCP_MAX_BODY_BYTES = 8388608;
|
|
34719
|
+
/** Constant-time bearer comparison (the token is a shared secret). */
|
|
34720
|
+
function bearerMatches(provided, expected) {
|
|
34721
|
+
const a = Buffer.from(provided, "utf8");
|
|
34722
|
+
const b = Buffer.from(expected, "utf8");
|
|
34723
|
+
if (a.length !== b.length) return false;
|
|
34724
|
+
return timingSafeEqual(a, b);
|
|
34670
34725
|
}
|
|
34671
34726
|
function sendJson(res, status, body) {
|
|
34672
34727
|
const text = JSON.stringify(body);
|
|
@@ -34706,7 +34761,7 @@ function startMcpBridge(opts) {
|
|
|
34706
34761
|
const server = createServer((req, res) => {
|
|
34707
34762
|
(async () => {
|
|
34708
34763
|
const url = (req.url ?? "").split("?")[0];
|
|
34709
|
-
if (String(req.headers["authorization"] ?? "")
|
|
34764
|
+
if (!bearerMatches(String(req.headers["authorization"] ?? ""), "Bearer " + token)) {
|
|
34710
34765
|
sendJson(res, 401, { error: "unauthorized" });
|
|
34711
34766
|
return;
|
|
34712
34767
|
}
|
|
@@ -34737,14 +34792,16 @@ function startMcpBridge(opts) {
|
|
|
34737
34792
|
return;
|
|
34738
34793
|
}
|
|
34739
34794
|
if (req.method === "POST" && (url === "/call" || url === "/mcp/call")) {
|
|
34740
|
-
const body = await
|
|
34741
|
-
|
|
34742
|
-
|
|
34743
|
-
|
|
34744
|
-
}
|
|
34795
|
+
const body = await readJsonBody(req, { maxBytes: opts.maxBodyBytes ?? MCP_MAX_BODY_BYTES });
|
|
34796
|
+
if (body.tooLarge) {
|
|
34797
|
+
sendJson(res, 413, { error: "request body too large" });
|
|
34798
|
+
return;
|
|
34799
|
+
}
|
|
34800
|
+
if (body.invalid) {
|
|
34745
34801
|
sendJson(res, 400, { error: "invalid JSON" });
|
|
34746
34802
|
return;
|
|
34747
34803
|
}
|
|
34804
|
+
const parsed = body.value;
|
|
34748
34805
|
const svc = opts.tools();
|
|
34749
34806
|
if (!svc) {
|
|
34750
34807
|
sendJson(res, 503, { error: "tools service unavailable" });
|
|
@@ -35462,20 +35519,10 @@ function apply(ctx, entryConfig = {}) {
|
|
|
35462
35519
|
res.writeHead(status, { "Content-Type": "application/json; charset=utf-8" });
|
|
35463
35520
|
res.end(JSON.stringify(body));
|
|
35464
35521
|
};
|
|
35465
|
-
const readBody = (req, strict = false) => {
|
|
35466
|
-
const
|
|
35467
|
-
|
|
35468
|
-
|
|
35469
|
-
r.on?.("data", (c) => chunks.push(c));
|
|
35470
|
-
r.on?.("end", () => {
|
|
35471
|
-
try {
|
|
35472
|
-
const v = JSON.parse(Buffer.concat(chunks).toString("utf8"));
|
|
35473
|
-
resolve(v && typeof v === "object" ? v : {});
|
|
35474
|
-
} catch {
|
|
35475
|
-
resolve(strict ? { __invalidJson: true } : {});
|
|
35476
|
-
}
|
|
35477
|
-
});
|
|
35478
|
-
});
|
|
35522
|
+
const readBody = async (req, strict = false) => {
|
|
35523
|
+
const result = await readJsonBody(req);
|
|
35524
|
+
if (result.invalid || result.tooLarge) return strict ? { __invalidJson: true } : {};
|
|
35525
|
+
return result.value;
|
|
35479
35526
|
};
|
|
35480
35527
|
const methodOf = (req) => {
|
|
35481
35528
|
const m = req.method;
|
package/docs/KNOWN-GAPS.md
CHANGED
|
@@ -116,7 +116,10 @@ reference-counted slot is the strongest isolation the plugin can provide.
|
|
|
116
116
|
restricts the served set **and is
|
|
117
117
|
enforced at execution time** — a `/call` for a tool outside the allowlist is
|
|
118
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.
|
|
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.
|
|
120
123
|
Every bridged call is audit-logged as `mcp call <tool> [<outcome>] <ms>` with
|
|
121
124
|
the caller class and the failure text (never the arguments), so a failed run
|
|
122
125
|
is visible in journald instead of only in transcripts.
|
package/docs/SECURITY-NOTES.md
CHANGED
|
@@ -44,3 +44,15 @@ choice, not a fallback applied by this plugin.
|
|
|
44
44
|
`/agy doctor` writes its report with redaction: Google auth URLs,
|
|
45
45
|
`4/...` authorization codes, `ya29.*` tokens, and `Bearer` headers are
|
|
46
46
|
stripped before the file hits disk. Still read it before attaching.
|
|
47
|
+
|
|
48
|
+
## HTTP surfaces and request-body caps
|
|
49
|
+
|
|
50
|
+
- The plugin's web routes (`/plugins/agy-link/*`) and the experimental loopback
|
|
51
|
+
MCP bridge (`/call`) share one bounded JSON body reader
|
|
52
|
+
(`src/host/read-json-body.ts`): bodies are capped (1 MiB for plugin routes,
|
|
53
|
+
8 MiB for `/call`) and oversized bodies are discarded rather than buffered, so
|
|
54
|
+
a local client — including a prompt-injected agy child holding the bridge token
|
|
55
|
+
— cannot exhaust host memory by streaming a huge POST body.
|
|
56
|
+
- The MCP bridge binds to `127.0.0.1` only, requires a per-process random bearer
|
|
57
|
+
token compared in constant time, enforces `mcpToolAllowlist` at execution time,
|
|
58
|
+
and never exposes the internal `run_code`/`agy_ask`/`agy_tool` tools.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-agy-link",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.78",
|
|
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",
|