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 +28 -0
- package/README.md +1 -0
- package/bin/deepseek.js +24 -8
- package/package.json +2 -1
- package/runner/runner.js +37 -17
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
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
|