deepseek-cli-mode 1.0.0 → 1.0.2
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 +36 -0
- package/README.md +1 -0
- package/bin/deepseek.js +39 -10
- package/package.json +2 -1
- package/runner/runner.js +37 -17
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 1.0.2 — runtime discovery fixes
|
|
4
|
+
|
|
5
|
+
- `resolveDshBin()` now also looks for `@deepseek-ai/dsh` next to this package (same `node_modules`) and in the
|
|
6
|
+
`node_modules` of every directory on `PATH`. This fixes "cannot find @deepseek-ai/dsh" when dsh is installed under a
|
|
7
|
+
custom npm prefix (including Windows drive-relative prefixes such as `E:\root\.npm-global`).
|
|
8
|
+
- The not-found message now explains the Windows drive-relative `prefix=/root/.npm-global` pitfall and points at
|
|
9
|
+
`npm root -g`.
|
|
10
|
+
|
|
11
|
+
## 1.0.1 — security fixes
|
|
12
|
+
|
|
13
|
+
Fixes from an independent security audit (Cloudflare `security-audit` skill, quick profile).
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- **Repository-local runtime execution (Medium).** `resolveDshBin()` no longer considers
|
|
18
|
+
`process.cwd()/node_modules/@deepseek-ai/dsh` or a cwd-based `require` resolution. Running `deepseek` inside an
|
|
19
|
+
untrusted repository can no longer execute that repository's own Harness copy. A project-local runtime is available
|
|
20
|
+
only with the explicit `DSH_CLI_ALLOW_LOCAL=1` opt-in, and the resolved package's manifest must declare
|
|
21
|
+
`name: "@deepseek-ai/dsh"`.
|
|
22
|
+
- **One-shot plan-review auto-approval (Medium).** Non-interactive runs no longer select the first option of a
|
|
23
|
+
question. They now answer with a refusal, which keeps plan mode active instead of approving a plan no human
|
|
24
|
+
reviewed.
|
|
25
|
+
- **Terminal escape injection (Medium).** Model- and repository-derived text (assistant output, tool-call previews,
|
|
26
|
+
todo items, reasoning, question fields, error messages, and the one-shot answer) is stripped of C0/C1 control
|
|
27
|
+
characters before it reaches the terminal.
|
|
28
|
+
|
|
29
|
+
### Changed
|
|
30
|
+
|
|
31
|
+
- `DSH_CLI_ALLOW_LOCAL=1` is a new opt-in for executing a runtime installed inside the current project.
|
|
32
|
+
|
|
33
|
+
## 1.0.0 — first public release
|
|
34
|
+
|
|
35
|
+
- Interactive terminal mode for DeepSeek Harness: streaming, tools, approvals, plan mode, goals, compaction, skills,
|
|
36
|
+
jobs, todos, subagents, image/file attachments, session resume, and `/export`.
|
package/README.md
CHANGED
|
@@ -152,6 +152,7 @@ when a tool wants to leave the workspace or act outside the sandbox policy.
|
|
|
152
152
|
| `DSH_HOME` | `~/.dsh` | Harness home (profiles, sessions, credentials) |
|
|
153
153
|
| `DEEPSEEK_API_KEY` | stored key | API key override |
|
|
154
154
|
| `DSH_CLI_DSH_BIN` | auto-detected | explicit path to `@deepseek-ai/dsh/lib/bin.js` |
|
|
155
|
+
| `DSH_CLI_ALLOW_LOCAL` | unset | `1` allows executing a dsh installed inside the current project; leave unset in untrusted repositories |
|
|
155
156
|
| `DSH_PERMISSION_MODE` | `workspace-write` | `read-only` \| `workspace-write` \| `danger-full-access` |
|
|
156
157
|
| `DSH_CLI_REASONING` | unset | `1` streams reasoning text instead of collapsing it |
|
|
157
158
|
| `DSH_CLI_YES` | unset | `1` auto-approves tool actions (same as `--yes`) |
|
package/bin/deepseek.js
CHANGED
|
@@ -12,7 +12,7 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
12
12
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
13
13
|
import { createRequire } from "node:module";
|
|
14
14
|
import { homedir } from "node:os";
|
|
15
|
-
import { dirname, join, resolve } from "node:path";
|
|
15
|
+
import { dirname, delimiter, join, resolve } from "node:path";
|
|
16
16
|
import { fileURLToPath } from "node:url";
|
|
17
17
|
|
|
18
18
|
const here = dirname(fileURLToPath(import.meta.url));
|
|
@@ -81,6 +81,9 @@ Requires a local install of @deepseek-ai/dsh (npm i -g @deepseek-ai/dsh).`);
|
|
|
81
81
|
function resolveDshBin() {
|
|
82
82
|
const candidates = [
|
|
83
83
|
process.env.DSH_CLI_DSH_BIN,
|
|
84
|
+
// Sibling install: dsh next to this package in the same node_modules.
|
|
85
|
+
// This is the normal layout when both were installed with one npm prefix.
|
|
86
|
+
join(root, "..", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
84
87
|
// Same global prefix as this node binary (npm default layout).
|
|
85
88
|
join(dirname(process.execPath), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
86
89
|
// Common global prefixes.
|
|
@@ -89,15 +92,25 @@ function resolveDshBin() {
|
|
|
89
92
|
process.env.npm_config_prefix ? join(process.env.npm_config_prefix, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js") : undefined,
|
|
90
93
|
join(homedir(), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
91
94
|
join(homedir(), ".npm-global", "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
92
|
-
join(process.cwd(), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
|
|
93
95
|
].filter(Boolean);
|
|
96
|
+
// A project-local runtime is OPT-IN: running the CLI inside an untrusted
|
|
97
|
+
// repository must never execute repository-supplied code implicitly.
|
|
98
|
+
if (process.env.DSH_CLI_ALLOW_LOCAL === "1") {
|
|
99
|
+
candidates.push(join(process.cwd(), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"));
|
|
100
|
+
}
|
|
94
101
|
for (const candidate of candidates) if (existsSync(candidate)) return candidate;
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
102
|
+
// npm's own bin directories are on PATH; check their sibling node_modules.
|
|
103
|
+
// This covers custom prefixes (and Windows drive-relative prefixes) that the
|
|
104
|
+
// fixed candidates above cannot know about.
|
|
105
|
+
for (const dir of String(process.env.PATH ?? "").split(delimiter)) {
|
|
106
|
+
if (dir === "") continue;
|
|
107
|
+
const candidate = join(dir, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js");
|
|
108
|
+
if (existsSync(candidate)) return candidate;
|
|
100
109
|
}
|
|
110
|
+
// Resolve only from this package's own module graph (never from cwd).
|
|
111
|
+
try {
|
|
112
|
+
return requireFromHere.resolve("@deepseek-ai/dsh/lib/bin.js");
|
|
113
|
+
} catch {}
|
|
101
114
|
// Last resort: ask npm where its global root is (covers custom prefixes).
|
|
102
115
|
try {
|
|
103
116
|
const npmCli = join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
|
|
@@ -202,13 +215,29 @@ const dshBin = resolveDshBin();
|
|
|
202
215
|
if (dshBin === undefined) {
|
|
203
216
|
console.error(
|
|
204
217
|
"deepseek: cannot find @deepseek-ai/dsh.\n" +
|
|
205
|
-
" Install it once: npm i -g @deepseek-ai/dsh\n" +
|
|
206
|
-
" Or point DSH_CLI_DSH_BIN at .../@deepseek-ai/dsh/lib/bin.js"
|
|
218
|
+
" Install it once in the same npm prefix as this CLI: npm i -g @deepseek-ai/dsh\n" +
|
|
219
|
+
" Or point DSH_CLI_DSH_BIN at .../@deepseek-ai/dsh/lib/bin.js\n" +
|
|
220
|
+
" If it is installed but not found, check `npm root -g` (a POSIX-style npm prefix such as\n" +
|
|
221
|
+
" /root/.npm-global resolves per drive on Windows; use C:\\... instead)."
|
|
222
|
+
);
|
|
223
|
+
process.exit(1);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// Verify the resolved package really is the Harness runtime before executing
|
|
227
|
+
// it. This blocks a path that merely exists at a trusted prefix but is not dsh.
|
|
228
|
+
let dshManifest;
|
|
229
|
+
try {
|
|
230
|
+
dshManifest = JSON.parse(readFileSync(join(dshBin, "..", "..", "package.json"), "utf8"));
|
|
231
|
+
} catch {}
|
|
232
|
+
if (dshManifest?.name !== "@deepseek-ai/dsh") {
|
|
233
|
+
console.error(
|
|
234
|
+
`deepseek: resolved ${dshBin} is not @deepseek-ai/dsh (found ${JSON.stringify(dshManifest?.name ?? "no manifest")}).\n` +
|
|
235
|
+
" Point DSH_CLI_DSH_BIN at a real @deepseek-ai/dsh/lib/bin.js."
|
|
207
236
|
);
|
|
208
237
|
process.exit(1);
|
|
209
238
|
}
|
|
210
239
|
|
|
211
|
-
const dshVersion =
|
|
240
|
+
const dshVersion = String(dshManifest.version ?? "0.0.0");
|
|
212
241
|
if (compareVersions(dshVersion, DSH_MIN) < 0) {
|
|
213
242
|
console.error(
|
|
214
243
|
`deepseek: @deepseek-ai/dsh ${dshVersion} is too old for this CLI (needs >= ${DSH_MIN}).\n` +
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "deepseek-cli-mode",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.2",
|
|
4
4
|
"description": "Terminal (CLI) mode for DeepSeek Harness — an interactive, Claude Code-style coding agent for your shell.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.js",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
"bin",
|
|
14
14
|
"runner",
|
|
15
15
|
"docs",
|
|
16
|
+
"CHANGELOG.md",
|
|
16
17
|
"index.js",
|
|
17
18
|
"README.md"
|
|
18
19
|
],
|
package/runner/runner.js
CHANGED
|
@@ -73,6 +73,16 @@ function formatTokens(n) {
|
|
|
73
73
|
return String(n);
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// C0/C1 control characters (except newline and tab). Model- and
|
|
77
|
+
// repository-derived text must never reach the terminal verbatim: ESC/OSC/CSI
|
|
78
|
+
// sequences enable title spoofing, clipboard writes, and forged prompts.
|
|
79
|
+
const CONTROL_CHARS = /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g;
|
|
80
|
+
|
|
81
|
+
/** Strip terminal control characters from untrusted text. */
|
|
82
|
+
function sanitize(text) {
|
|
83
|
+
return String(text).replace(CONTROL_CHARS, "");
|
|
84
|
+
}
|
|
85
|
+
|
|
76
86
|
// ---------------------------------------------------------------------------
|
|
77
87
|
// Markdown-lite line formatter (tty only; piped output stays raw)
|
|
78
88
|
// ---------------------------------------------------------------------------
|
|
@@ -123,16 +133,16 @@ function previewToolCall(name, rawArgs) {
|
|
|
123
133
|
try {
|
|
124
134
|
args = JSON.parse(rawArgs);
|
|
125
135
|
} catch {
|
|
126
|
-
return truncate(rawArgs ?? "", 80);
|
|
136
|
+
return sanitize(truncate(rawArgs ?? "", 80));
|
|
127
137
|
}
|
|
128
138
|
if (args === null || typeof args !== "object") return "";
|
|
129
139
|
const pick = args.path ?? args.file_path ?? args.filePath ?? args.command ?? args.cmd
|
|
130
140
|
?? args.pattern ?? args.query ?? args.url ?? args.description ?? args.task ?? args.id;
|
|
131
141
|
if (typeof pick !== "string") {
|
|
132
142
|
const json = JSON.stringify(args);
|
|
133
|
-
return json && json !== "{}" ? truncate(json, 80) : "";
|
|
143
|
+
return json && json !== "{}" ? sanitize(truncate(json, 80)) : "";
|
|
134
144
|
}
|
|
135
|
-
return truncate(pick.replace(/\s+/g, " "), 90);
|
|
145
|
+
return sanitize(truncate(pick.replace(/\s+/g, " "), 90));
|
|
136
146
|
}
|
|
137
147
|
|
|
138
148
|
// ---------------------------------------------------------------------------
|
|
@@ -164,7 +174,7 @@ function createRenderer({ write, capture }) {
|
|
|
164
174
|
|
|
165
175
|
const feed = (text) => {
|
|
166
176
|
if (capture) return;
|
|
167
|
-
buffer += text;
|
|
177
|
+
buffer += sanitize(text);
|
|
168
178
|
let index;
|
|
169
179
|
while ((index = buffer.indexOf("\n")) !== -1) {
|
|
170
180
|
const line = buffer.slice(0, index);
|
|
@@ -220,9 +230,10 @@ function createRenderer({ write, capture }) {
|
|
|
220
230
|
streamedThisTurn = true;
|
|
221
231
|
feed(chunk.text);
|
|
222
232
|
} else if (chunk.type === "reasoning-delta") {
|
|
223
|
-
|
|
233
|
+
const safe = sanitize(chunk.text);
|
|
234
|
+
reasoning += safe;
|
|
224
235
|
if (showReasoning && !capture) {
|
|
225
|
-
write(paint(ansi.dim, paint(ansi.italic,
|
|
236
|
+
write(paint(ansi.dim, paint(ansi.italic, safe)));
|
|
226
237
|
lineDirty = true;
|
|
227
238
|
}
|
|
228
239
|
}
|
|
@@ -242,7 +253,7 @@ function createRenderer({ write, capture }) {
|
|
|
242
253
|
}
|
|
243
254
|
case "tool/result": {
|
|
244
255
|
if (data.error) {
|
|
245
|
-
write(paint(ansi.dim, paint(ansi.italic, ` ✗ ${data.error.name}: ${data.error.code}`)) + "\n");
|
|
256
|
+
write(paint(ansi.dim, paint(ansi.italic, ` ✗ ${sanitize(data.error.name)}: ${sanitize(data.error.code)}`)) + "\n");
|
|
246
257
|
}
|
|
247
258
|
break;
|
|
248
259
|
}
|
|
@@ -279,7 +290,7 @@ function createRenderer({ write, capture }) {
|
|
|
279
290
|
: todo.status === "in_progress"
|
|
280
291
|
? paint(ansi.yellow, "▸")
|
|
281
292
|
: paint(ansi.dim, "☐");
|
|
282
|
-
const content = todo.status === "completed" ? paint(ansi.dim, todo.content) : todo.content;
|
|
293
|
+
const content = todo.status === "completed" ? paint(ansi.dim, sanitize(todo.content)) : sanitize(todo.content);
|
|
283
294
|
write(` ${mark} ${content}\n`);
|
|
284
295
|
}
|
|
285
296
|
break;
|
|
@@ -290,7 +301,7 @@ function createRenderer({ write, capture }) {
|
|
|
290
301
|
const kind = data.reason?.kind;
|
|
291
302
|
lastTurnKind = kind;
|
|
292
303
|
if (kind === "error") {
|
|
293
|
-
write(paint(ansi.red, ` ⚠ turn failed: ${data.reason.error?.message ?? "unknown error"}\n`));
|
|
304
|
+
write(paint(ansi.red, ` ⚠ turn failed: ${sanitize(data.reason.error?.message ?? "unknown error")}\n`));
|
|
294
305
|
} else if (kind === "max-tokens") {
|
|
295
306
|
write(paint(ansi.yellow, " ⚠ hit the output-token ceiling\n"));
|
|
296
307
|
} else if (kind === "aborted") {
|
|
@@ -743,15 +754,21 @@ async function run(ctx, options, exit) {
|
|
|
743
754
|
const questions = request?.questions ?? [];
|
|
744
755
|
for (const question of questions) {
|
|
745
756
|
out("\n");
|
|
746
|
-
if (question.header) out(paint(ansi.bold, ` ${question.header}`) + "\n");
|
|
747
|
-
out(` ${question.question}\n`);
|
|
748
|
-
if (question.detail) out(paint(ansi.dim, question.detail.replace(/^/gm, " | ")) + "\n");
|
|
757
|
+
if (question.header) out(paint(ansi.bold, ` ${sanitize(question.header)}`) + "\n");
|
|
758
|
+
out(` ${sanitize(question.question)}\n`);
|
|
759
|
+
if (question.detail) out(paint(ansi.dim, sanitize(question.detail).replace(/^/gm, " | ")) + "\n");
|
|
749
760
|
const options = question.options ?? [];
|
|
750
761
|
options.forEach((option, index) => {
|
|
751
|
-
out(` ${paint(ansi.cyan, String(index + 1))}) ${option.label}${option.description ? paint(ansi.dim, ` — ${option.description}`) : ""}\n`);
|
|
762
|
+
out(` ${paint(ansi.cyan, String(index + 1))}) ${sanitize(option.label)}${option.description ? paint(ansi.dim, ` — ${sanitize(option.description)}`) : ""}\n`);
|
|
752
763
|
});
|
|
753
764
|
if (!interactive) {
|
|
754
|
-
|
|
765
|
+
// A non-interactive run has no human: never synthesize consent. The
|
|
766
|
+
// plan-review gate in particular must stay closed.
|
|
767
|
+
const refusal =
|
|
768
|
+
question.intent?.kind === "plan-review"
|
|
769
|
+
? "non-interactive run: plan review requires a human; staying in plan mode"
|
|
770
|
+
: "non-interactive run: this question needs an interactive terminal";
|
|
771
|
+
answers.push({ id: question.id, selected: [], custom: refusal });
|
|
755
772
|
continue;
|
|
756
773
|
}
|
|
757
774
|
const answer = await ask(paint(ansi.green, options.length > 0 ? " choose: " : " answer: "));
|
|
@@ -1027,7 +1044,10 @@ async function run(ctx, options, exit) {
|
|
|
1027
1044
|
}
|
|
1028
1045
|
await runTurn(agent, task);
|
|
1029
1046
|
const answer = renderer.lastAssistantText;
|
|
1030
|
-
if (answer !== "")
|
|
1047
|
+
if (answer !== "") {
|
|
1048
|
+
const safeAnswer = sanitize(answer);
|
|
1049
|
+
process.stdout.write(safeAnswer.endsWith("\n") ? safeAnswer : safeAnswer + "\n");
|
|
1050
|
+
}
|
|
1031
1051
|
await shutdown(renderer.lastTurnKind === "completed" ? 0 : 1);
|
|
1032
1052
|
return;
|
|
1033
1053
|
}
|
|
@@ -1136,7 +1156,7 @@ async function run(ctx, options, exit) {
|
|
|
1136
1156
|
case "/thinking": {
|
|
1137
1157
|
const reasoning = renderer.lastReasoning;
|
|
1138
1158
|
if (reasoning === "") out(paint(ansi.dim, " no reasoning recorded yet\n"));
|
|
1139
|
-
else out(paint(ansi.dim, paint(ansi.italic, reasoning)) + "\n");
|
|
1159
|
+
else out(paint(ansi.dim, paint(ansi.italic, sanitize(reasoning))) + "\n");
|
|
1140
1160
|
continue;
|
|
1141
1161
|
}
|
|
1142
1162
|
case "/image": {
|
|
@@ -1261,7 +1281,7 @@ async function run(ctx, options, exit) {
|
|
|
1261
1281
|
}
|
|
1262
1282
|
}
|
|
1263
1283
|
|
|
1264
|
-
out("\n" + paint(ansi.green, "❯ ") + text + "\n\n");
|
|
1284
|
+
out("\n" + paint(ansi.green, "❯ ") + sanitize(text) + "\n\n");
|
|
1265
1285
|
await runTurn(agent, text);
|
|
1266
1286
|
}
|
|
1267
1287
|
|