ladder-mcp 1.0.1 → 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 CHANGED
@@ -4,6 +4,32 @@ All notable changes to Ladder_mcp are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/); this project uses
5
5
  [Semantic Versioning](https://semver.org/).
6
6
 
7
+ ## [1.0.2] - 2026-06-28
8
+
9
+ Security and robustness patch release addressing findings from an external
10
+ adversarial review of `src/`.
11
+
12
+ ### Fixed
13
+
14
+ - **`isAuthenticated` false-positive on corrupt credentials** (`environment.ts`):
15
+ a credentials file that exists but is unreadable or invalid JSON was previously
16
+ reported as authenticated. It now fails closed and returns `false`, satisfying
17
+ NFR-5 honest diagnostics.
18
+ - **Unbounded `stdout`/`stderr` capture in `runKimi`** (`kimi-runner.ts`): CLI
19
+ output is now accumulated through `appendCapped` with a hard ceiling
20
+ (`MAX_CAPTURE_CHARS`), preventing memory exhaustion on runaway output.
21
+ - **Unvalidated `max_output_tokens` and `work_dir`** (`input-validation.ts`):
22
+ non-finite, zero, or negative token counts are clamped to the default budget;
23
+ `work_dir` must be an absolute path to an existing directory before Kimi is
24
+ spawned. Used by `kimi_analyze` and `kimi_resume`.
25
+
26
+ ### Security
27
+
28
+ - **Arbitrary command persisted to `mcp.json`** (`kimi-mcp-config.ts`):
29
+ `kimi_generate_mcp_config` now validates `command` against an allow-list of
30
+ `node`, `npx`, or an absolute path to an existing file. Other values (e.g.
31
+ `powershell`) are rejected and nothing is written.
32
+
7
33
  ## [1.0.1] - 2026-06-28
8
34
 
9
35
  Bug-fix release. Issues found by exercising all 20 tools live against a real
@@ -126,7 +126,10 @@ export function isAuthenticated(options) {
126
126
  return Boolean(data.access_token || data.refresh_token || data.id_token || data.token);
127
127
  }
128
128
  catch {
129
- return true;
129
+ // A corrupt or unreadable credentials file is NOT proof of authentication.
130
+ // Returning true here produced a false-positive "authenticated" status that
131
+ // misled diagnostics (NFR-5). Fail closed.
132
+ return false;
130
133
  }
131
134
  }
132
135
  export function isKimiInstalled(options) {
package/dist/index.js CHANGED
@@ -2,6 +2,7 @@
2
2
  import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
3
3
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
4
  import { z } from 'zod';
5
+ import { maxChars, validateWorkDir } from './input-validation.js';
5
6
  import { getKimiStatus } from './environment.js';
6
7
  import { runKimiApi, isApiConfigured } from './kimi-api.js';
7
8
  import { runKimi } from './kimi-runner.js';
@@ -13,9 +14,8 @@ import { generateMcpConfig } from './kimi-mcp-config.js';
13
14
  import { buildBudgetProbeGuide, getDesktopStatus } from './desktop-work.js';
14
15
  const server = new McpServer({
15
16
  name: 'kimi-code',
16
- version: '1.0.0',
17
+ version: '1.0.2',
17
18
  });
18
- const DEFAULT_MAX_OUTPUT_CHARS = 60_000;
19
19
  const FORMAT_INSTRUCTIONS = {
20
20
  summary: `
21
21
  OUTPUT FORMAT CONSTRAINTS:
@@ -42,9 +42,6 @@ IMPORTANT: Your response will be consumed by another AI model with limited conte
42
42
  function wrapPrompt(prompt, detailLevel) {
43
43
  return `${prompt}\n${FORMAT_INSTRUCTIONS[detailLevel]}\n${AI_CONSUMER_NOTICE}`;
44
44
  }
45
- function maxChars(maxOutputTokens) {
46
- return maxOutputTokens ? maxOutputTokens * 4 : DEFAULT_MAX_OUTPUT_CHARS;
47
- }
48
45
  function textResponse(text, isError = false) {
49
46
  return { content: [{ type: 'text', text }], isError };
50
47
  }
@@ -60,6 +57,9 @@ server.tool('kimi_analyze', 'Send a prompt to Kimi Code for codebase analysis. D
60
57
  max_output_tokens: z.number().optional(),
61
58
  include_thinking: z.boolean().optional(),
62
59
  }, async ({ prompt, work_dir, session_id, new_session, detail_level, max_output_tokens, include_thinking }) => {
60
+ const workDirError = validateWorkDir(work_dir);
61
+ if (workDirError)
62
+ return textResponse(`Error: ${workDirError}`, true);
63
63
  const result = await runKimi({
64
64
  prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
65
65
  workDir: work_dir,
@@ -107,6 +107,9 @@ server.tool('kimi_resume', 'Resume an explicit Kimi Code session with a new prom
107
107
  max_output_tokens: z.number().optional(),
108
108
  include_thinking: z.boolean().optional(),
109
109
  }, async ({ session_id, prompt, work_dir, detail_level, max_output_tokens, include_thinking }) => {
110
+ const workDirError = validateWorkDir(work_dir);
111
+ if (workDirError)
112
+ return textResponse(`Error: ${workDirError}`, true);
110
113
  const result = await runKimi({
111
114
  prompt: wrapPrompt(prompt, detail_level ?? 'normal'),
112
115
  workDir: work_dir,
@@ -0,0 +1,29 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ export const DEFAULT_MAX_OUTPUT_CHARS = 60_000;
4
+ // Convert an untrusted max_output_tokens into a character budget. NaN/Infinity/zero/
5
+ // negative/non-integer would otherwise produce a nonsensical budget (e.g. NaN) handed
6
+ // to truncation, so clamp those to the default.
7
+ export function maxChars(maxOutputTokens) {
8
+ if (typeof maxOutputTokens !== 'number' || !Number.isFinite(maxOutputTokens) || maxOutputTokens <= 0) {
9
+ return DEFAULT_MAX_OUTPUT_CHARS;
10
+ }
11
+ return Math.floor(maxOutputTokens) * 4;
12
+ }
13
+ // Reject a work_dir before spawning Kimi: a relative or non-existent directory would
14
+ // otherwise be passed to the CLI as cwd and fail opaquely. Returns an error string when
15
+ // invalid, or undefined when the path is an existing absolute directory.
16
+ export function validateWorkDir(workDir) {
17
+ if (!path.isAbsolute(workDir))
18
+ return 'work_dir must be an absolute path.';
19
+ let stat;
20
+ try {
21
+ stat = fs.statSync(workDir);
22
+ }
23
+ catch {
24
+ return `work_dir does not exist: ${workDir}`;
25
+ }
26
+ if (!stat.isDirectory())
27
+ return `work_dir is not a directory: ${workDir}`;
28
+ return undefined;
29
+ }
@@ -44,6 +44,19 @@ function assertWritableProjectTarget(projectDir) {
44
44
  throw new Error('Refusing to write MCP config under read-only kimi-code-mcp reference tree.');
45
45
  }
46
46
  }
47
+ const ALLOWED_BARE_COMMANDS = new Set(['node', 'npx']);
48
+ // Constrain the launcher written into mcp.json. Without this, an arbitrary `command`
49
+ // (e.g. "powershell") would be persisted and later executed when Kimi loads the
50
+ // server config. Allow only the known Node launchers by bare name, or an absolute
51
+ // path to an existing file (a vetted local binary).
52
+ function assertSafeCommand(command) {
53
+ const value = command.trim();
54
+ if (ALLOWED_BARE_COMMANDS.has(value))
55
+ return;
56
+ if (path.isAbsolute(value) && fs.existsSync(value) && fs.statSync(value).isFile())
57
+ return;
58
+ throw new Error(`command must be one of [${[...ALLOWED_BARE_COMMANDS].join(', ')}] or an absolute path to an existing file; got "${command}".`);
59
+ }
47
60
  function readMcpServers(existing) {
48
61
  const value = existing.mcpServers;
49
62
  if (value === undefined || value === null)
@@ -66,8 +79,10 @@ export function generateMcpConfig(options = {}) {
66
79
  throw new Error('server_name must contain only letters, digits, underscores, and hyphens.');
67
80
  }
68
81
  const defaults = defaultServerCommand();
82
+ const command = options.command ?? defaults.command;
83
+ assertSafeCommand(command);
69
84
  const serverConfig = {
70
- command: options.command ?? defaults.command,
85
+ command,
71
86
  args: options.args ?? defaults.args,
72
87
  env: {},
73
88
  };
@@ -1,6 +1,16 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import * as path from 'node:path';
3
3
  import { buildKimiEnv, resolveKimiPaths } from './environment.js';
4
+ const MAX_CAPTURE_CHARS = 16 * 1024 * 1024;
5
+ // Append to a captured stream while enforcing a hard ceiling so a runaway CLI cannot
6
+ // grow the buffer without bound. Once the cap is reached, further data is dropped (the
7
+ // stream is still drained by Node, just not stored).
8
+ export function appendCapped(current, addition, max) {
9
+ if (current.length >= max)
10
+ return current;
11
+ const next = current + addition;
12
+ return next.length > max ? next.slice(0, max) : next;
13
+ }
4
14
  export function buildKimiArgs(config) {
5
15
  const args = ['-p', config.prompt, '--output-format', 'stream-json'];
6
16
  if (config.sessionId) {
@@ -120,8 +130,8 @@ export function runKimi(config) {
120
130
  killProcessTree(proc.pid);
121
131
  finish({ ok: false, text: '', error: `Kimi timed out after ${Math.round(timeoutMs / 1000)}s` });
122
132
  }, timeoutMs);
123
- proc.stdout?.on('data', (chunk) => { stdout += chunk.toString('utf-8'); });
124
- proc.stderr?.on('data', (chunk) => { stderr += chunk.toString('utf-8'); });
133
+ proc.stdout?.on('data', (chunk) => { stdout = appendCapped(stdout, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
134
+ proc.stderr?.on('data', (chunk) => { stderr = appendCapped(stderr, chunk.toString('utf-8'), MAX_CAPTURE_CHARS); });
125
135
  proc.on('error', (err) => {
126
136
  finish({ ok: false, text: '', error: err instanceof Error ? err.message : String(err) });
127
137
  });
@@ -221,7 +221,7 @@ export class AcpClient extends EventEmitter {
221
221
  async initialize() {
222
222
  return this.request('initialize', {
223
223
  protocolVersion: 1,
224
- clientInfo: { name: 'ladder-mcp', version: '1.0.0' },
224
+ clientInfo: { name: 'ladder-mcp', version: '1.0.2' },
225
225
  capabilities: {},
226
226
  }, 30_000);
227
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ladder-mcp",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Windows-first MCP bridge for Kimi Code CLI v24",
5
5
  "license": "MIT",
6
6
  "type": "module",