deepseek-cli-mode 1.0.0 → 1.0.1

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,28 @@
1
+ # Changelog
2
+
3
+ ## 1.0.1 — security fixes
4
+
5
+ Fixes from an independent security audit (Cloudflare `security-audit` skill, quick profile).
6
+
7
+ ### Fixed
8
+
9
+ - **Repository-local runtime execution (Medium).** `resolveDshBin()` no longer considers
10
+ `process.cwd()/node_modules/@deepseek-ai/dsh` or a cwd-based `require` resolution. Running `deepseek` inside an
11
+ untrusted repository can no longer execute that repository's own Harness copy. A project-local runtime is available
12
+ only with the explicit `DSH_CLI_ALLOW_LOCAL=1` opt-in, and the resolved package's manifest must declare
13
+ `name: "@deepseek-ai/dsh"`.
14
+ - **One-shot plan-review auto-approval (Medium).** Non-interactive runs no longer select the first option of a
15
+ question. They now answer with a refusal, which keeps plan mode active instead of approving a plan no human
16
+ reviewed.
17
+ - **Terminal escape injection (Medium).** Model- and repository-derived text (assistant output, tool-call previews,
18
+ todo items, reasoning, question fields, error messages, and the one-shot answer) is stripped of C0/C1 control
19
+ characters before it reaches the terminal.
20
+
21
+ ### Changed
22
+
23
+ - `DSH_CLI_ALLOW_LOCAL=1` is a new opt-in for executing a runtime installed inside the current project.
24
+
25
+ ## 1.0.0 — first public release
26
+
27
+ - Interactive terminal mode for DeepSeek Harness: streaming, tools, approvals, plan mode, goals, compaction, skills,
28
+ 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
@@ -89,15 +89,17 @@ function resolveDshBin() {
89
89
  process.env.npm_config_prefix ? join(process.env.npm_config_prefix, "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js") : undefined,
90
90
  join(homedir(), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"),
91
91
  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
92
  ].filter(Boolean);
94
- 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 {}
93
+ // A project-local runtime is OPT-IN: running the CLI inside an untrusted
94
+ // repository must never execute repository-supplied code implicitly.
95
+ if (process.env.DSH_CLI_ALLOW_LOCAL === "1") {
96
+ candidates.push(join(process.cwd(), "node_modules", "@deepseek-ai", "dsh", "lib", "bin.js"));
100
97
  }
98
+ for (const candidate of candidates) if (existsSync(candidate)) return candidate;
99
+ // Resolve only from this package's own module graph (never from cwd).
100
+ try {
101
+ return requireFromHere.resolve("@deepseek-ai/dsh/lib/bin.js");
102
+ } catch {}
101
103
  // Last resort: ask npm where its global root is (covers custom prefixes).
102
104
  try {
103
105
  const npmCli = join(dirname(process.execPath), "node_modules", "npm", "bin", "npm-cli.js");
@@ -208,7 +210,21 @@ if (dshBin === undefined) {
208
210
  process.exit(1);
209
211
  }
210
212
 
211
- const dshVersion = readVersion(join(dshBin, "..", "..", "package.json"));
213
+ // Verify the resolved package really is the Harness runtime before executing
214
+ // it. This blocks a path that merely exists at a trusted prefix but is not dsh.
215
+ let dshManifest;
216
+ try {
217
+ dshManifest = JSON.parse(readFileSync(join(dshBin, "..", "..", "package.json"), "utf8"));
218
+ } catch {}
219
+ if (dshManifest?.name !== "@deepseek-ai/dsh") {
220
+ console.error(
221
+ `deepseek: resolved ${dshBin} is not @deepseek-ai/dsh (found ${JSON.stringify(dshManifest?.name ?? "no manifest")}).\n` +
222
+ " Point DSH_CLI_DSH_BIN at a real @deepseek-ai/dsh/lib/bin.js."
223
+ );
224
+ process.exit(1);
225
+ }
226
+
227
+ const dshVersion = String(dshManifest.version ?? "0.0.0");
212
228
  if (compareVersions(dshVersion, DSH_MIN) < 0) {
213
229
  console.error(
214
230
  `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.1",
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