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 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
- // Resolve like a normal import from this package, then from the cwd.
96
- for (const req of [requireFromHere, createRequire(join(process.cwd(), "resolve-dsh.js"))]) {
97
- try {
98
- return req.resolve("@deepseek-ai/dsh/lib/bin.js");
99
- } catch {}
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 = readVersion(join(dshBin, "..", "..", "package.json"));
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.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
- reasoning += chunk.text;
233
+ const safe = sanitize(chunk.text);
234
+ reasoning += safe;
224
235
  if (showReasoning && !capture) {
225
- write(paint(ansi.dim, paint(ansi.italic, chunk.text)));
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
- answers.push({ id: question.id, selected: options[0] ? [options[0].label] : [], custom: options[0] ? undefined : "one-shot mode: no interactive answer" });
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 !== "") process.stdout.write(answer.endsWith("\n") ? answer : answer + "\n");
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