bitbank-lab-mcp 0.2.0 → 0.2.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/lib/validate.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { realpathSync } from 'node:fs';
|
|
2
|
+
import { basename, delimiter, dirname, join, resolve, sep } from 'node:path';
|
|
1
3
|
import type { Pair } from '../src/schemas.js';
|
|
2
4
|
import { nowIso } from './datetime.js';
|
|
3
5
|
|
|
@@ -148,6 +150,74 @@ export function validateDate(
|
|
|
148
150
|
return { ok: true, value: date };
|
|
149
151
|
}
|
|
150
152
|
|
|
153
|
+
/** チャートファイルの既定出力ディレクトリ(Claude.ai 環境の出力先) */
|
|
154
|
+
export const DEFAULT_CHART_OUTPUT_DIR = '/mnt/user-data/outputs';
|
|
155
|
+
|
|
156
|
+
/** チャート出力の許可 root を運用側が追加する環境変数(path.delimiter 区切り) */
|
|
157
|
+
export const OUTPUT_DIR_ALLOWLIST_ENV = 'BACKTEST_OUTPUT_DIR_ALLOWLIST';
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* チャート出力先として許可される root ディレクトリ一覧。
|
|
161
|
+
* 既定: DEFAULT_CHART_OUTPUT_DIR とサーバー作業ディレクトリ配下
|
|
162
|
+
* (相対パス指定・Cursor 等でワークスペース直下に書き出すユースケース用)。
|
|
163
|
+
* それ以外は運用側が環境変数で明示的に許可する(LLM 入力からは追加できない)。
|
|
164
|
+
*/
|
|
165
|
+
export function allowedOutputRoots(): string[] {
|
|
166
|
+
const extra = (process.env[OUTPUT_DIR_ALLOWLIST_ENV] ?? '')
|
|
167
|
+
.split(delimiter)
|
|
168
|
+
.map((p) => p.trim())
|
|
169
|
+
.filter((p) => p.length > 0)
|
|
170
|
+
.map((p) => resolve(p));
|
|
171
|
+
return [resolve(DEFAULT_CHART_OUTPUT_DIR), process.cwd(), ...extra];
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* パスを実パスに正規化する。実在する最深の祖先を realpathSync で解決し、
|
|
176
|
+
* 未作成の末尾セグメントは字句のまま結合する(mkdir 前の検証で使うため)。
|
|
177
|
+
* 許可 root 配下に置かれた symlink を経由した外部への書き込みを防ぐ。
|
|
178
|
+
*/
|
|
179
|
+
function canonicalizePath(p: string): string {
|
|
180
|
+
let current = resolve(p);
|
|
181
|
+
const pending: string[] = [];
|
|
182
|
+
for (;;) {
|
|
183
|
+
try {
|
|
184
|
+
return join(realpathSync(current), ...pending);
|
|
185
|
+
} catch {
|
|
186
|
+
const parent = dirname(current);
|
|
187
|
+
if (parent === current) return join(current, ...pending);
|
|
188
|
+
pending.unshift(basename(current));
|
|
189
|
+
current = parent;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* outputDir が許可 root 配下かを検証する。
|
|
196
|
+
* LLM 入力(プロンプトインジェクション含む)経由でプロセス権限内の任意パスへ
|
|
197
|
+
* ディレクトリ作成・ファイル書き込みされるのを防ぐ。`..` を含むパスも
|
|
198
|
+
* symlink も実パスに解決してから判定するため、トラバーサル・symlink では
|
|
199
|
+
* 迂回できない(検証と書き込みの間に symlink を差し替える TOCTOU は、
|
|
200
|
+
* それができる時点でローカルアクセスを持つため脅威モデル外)。
|
|
201
|
+
*/
|
|
202
|
+
export function ensureAllowedOutputDir(
|
|
203
|
+
dir: string,
|
|
204
|
+
): { ok: true; dir: string } | { ok: false; error: { type: 'user' | 'internal'; message: string } } {
|
|
205
|
+
const canonical = canonicalizePath(dir);
|
|
206
|
+
const roots = allowedOutputRoots();
|
|
207
|
+
const canonicalRoots = roots.map(canonicalizePath);
|
|
208
|
+
const allowed = canonicalRoots.some((root) => canonical === root || canonical.startsWith(root + sep));
|
|
209
|
+
if (!allowed) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
error: {
|
|
213
|
+
type: 'user',
|
|
214
|
+
message: `outputDir '${dir}' は許可されていません。許可 root: ${roots.join(', ')} 配下のみ(追加は環境変数 ${OUTPUT_DIR_ALLOWLIST_ENV} で指定)`,
|
|
215
|
+
},
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
return { ok: true, dir: canonical };
|
|
219
|
+
}
|
|
220
|
+
|
|
151
221
|
export function createMeta(
|
|
152
222
|
pair: Pair,
|
|
153
223
|
additional: Record<string, unknown> = {},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "bitbank-lab-mcp",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"description": "Experimental MCP server for bitbank — botters lab community edition. Market data, technical analysis, charting, and Private API trading tools.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/schema/backtest.ts
CHANGED
|
@@ -60,7 +60,15 @@ export const RunBacktestInputSchema = z
|
|
|
60
60
|
'One-way fee in basis points. When omitted, resolved dynamically from the current /spot/pairs taker rate (falls back to nominal 12 bp if unavailable). Explicit values are always respected.',
|
|
61
61
|
),
|
|
62
62
|
execution: z.literal('t+1_open').optional().default('t+1_open').describe('Execution timing (fixed: t+1_open)'),
|
|
63
|
-
outputDir: z
|
|
63
|
+
outputDir: z
|
|
64
|
+
.string()
|
|
65
|
+
.optional()
|
|
66
|
+
.default('/mnt/user-data/outputs')
|
|
67
|
+
.describe(
|
|
68
|
+
'Output directory for chart files. Must be under an allowed root: ' +
|
|
69
|
+
'/mnt/user-data/outputs, the server working directory, or a directory listed in ' +
|
|
70
|
+
'the BACKTEST_OUTPUT_DIR_ALLOWLIST environment variable.',
|
|
71
|
+
),
|
|
64
72
|
savePng: z
|
|
65
73
|
.boolean()
|
|
66
74
|
.optional()
|
|
@@ -49,6 +49,15 @@ export async function svgToPng(
|
|
|
49
49
|
return outputPath;
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
+
/**
|
|
53
|
+
* ファイル名に埋め込む値からパス区切り・ドット等を除去する。
|
|
54
|
+
* pair は上流(ensurePair)で検証済みだが、ファイル名の安全性を
|
|
55
|
+
* 上流バリデーションに依存させないための防御的サニタイズ。
|
|
56
|
+
*/
|
|
57
|
+
function sanitizeFilenamePart(part: string): string {
|
|
58
|
+
return part.replace(/[^a-zA-Z0-9_-]/g, '');
|
|
59
|
+
}
|
|
60
|
+
|
|
52
61
|
/**
|
|
53
62
|
* Generate a unique filename for backtest chart
|
|
54
63
|
*/
|
|
@@ -59,6 +68,6 @@ export function generateBacktestChartFilename(
|
|
|
59
68
|
format: 'png' | 'svg' = 'png',
|
|
60
69
|
): string {
|
|
61
70
|
const timestamp = dayjs().format('YYYY-MM-DDTHH-mm-ss');
|
|
62
|
-
const safePair = pair.replace('_', '');
|
|
63
|
-
return `backtest_${safePair}_${timeframe}_${strategy}_${timestamp}.${format}`;
|
|
71
|
+
const safePair = sanitizeFilenamePart(pair.replace('_', ''));
|
|
72
|
+
return `backtest_${safePair}_${sanitizeFilenamePart(timeframe)}_${sanitizeFilenamePart(strategy)}_${timestamp}.${format}`;
|
|
64
73
|
}
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { existsSync, mkdirSync } from 'node:fs';
|
|
8
8
|
import { join } from 'node:path';
|
|
9
9
|
import { dayjs, formatDateInTz } from '../../lib/datetime.js';
|
|
10
|
+
import { DEFAULT_CHART_OUTPUT_DIR, ensureAllowedOutputDir } from '../../lib/validate.js';
|
|
10
11
|
import { prependWarnings } from '../../lib/warning-propagation.js';
|
|
11
12
|
import { type BacktestEngineInput, type BacktestEngineResult, runBacktestEngine } from './lib/backtest_engine.js';
|
|
12
13
|
import { fetchCandlesForBacktest } from './lib/fetch_candles.js';
|
|
@@ -21,7 +22,7 @@ import {
|
|
|
21
22
|
import type { BacktestRange, Period, Timeframe } from './types.js';
|
|
22
23
|
|
|
23
24
|
// Claude.ai のデフォルト出力ディレクトリ
|
|
24
|
-
const DEFAULT_OUTPUT_DIR =
|
|
25
|
+
const DEFAULT_OUTPUT_DIR = DEFAULT_CHART_OUTPUT_DIR;
|
|
25
26
|
|
|
26
27
|
/**
|
|
27
28
|
* 書き込み可能なディレクトリを確保
|
|
@@ -48,7 +49,7 @@ export interface RunBacktestInput {
|
|
|
48
49
|
strategy: StrategyConfig;
|
|
49
50
|
fee_bp?: number;
|
|
50
51
|
execution?: 't+1_open';
|
|
51
|
-
/** 出力ディレクトリ(デフォルト: /mnt/user-data/outputs
|
|
52
|
+
/** 出力ディレクトリ(デフォルト: /mnt/user-data/outputs/)。許可 root 配下のみ(lib/validate.ts の ensureAllowedOutputDir) */
|
|
52
53
|
outputDir?: string;
|
|
53
54
|
/** PNG ファイルを生成する(デフォルト: false、ファイルシステム非共有のため) */
|
|
54
55
|
savePng?: boolean;
|
|
@@ -118,6 +119,17 @@ export default async function runBacktest(input: RunBacktestInput): Promise<RunB
|
|
|
118
119
|
}
|
|
119
120
|
const params = validation.normalizedParams;
|
|
120
121
|
|
|
122
|
+
// 出力ディレクトリをバリデーション(savePng 時のみ・fetch 前に弾く)。
|
|
123
|
+
// LLM 指定の任意パスへの書き込みを許可 root 配下に制限する。
|
|
124
|
+
let resolvedOutputDir: string | undefined;
|
|
125
|
+
if (savePng) {
|
|
126
|
+
const dirCheck = ensureAllowedOutputDir(outputDir);
|
|
127
|
+
if (!dirCheck.ok) {
|
|
128
|
+
return { ok: false, error: dirCheck.error.message };
|
|
129
|
+
}
|
|
130
|
+
resolvedOutputDir = dirCheck.dir;
|
|
131
|
+
}
|
|
132
|
+
|
|
121
133
|
// 必要なバー数を params から動的に計算(ユーザーが long / slow / signal 等を
|
|
122
134
|
// デフォルトより大きく指定した場合でもウォームアップを十分確保するため)
|
|
123
135
|
const requiredBars = strategy.computeRequiredBars(params);
|
|
@@ -214,11 +226,11 @@ export default async function runBacktest(input: RunBacktestInput): Promise<RunB
|
|
|
214
226
|
};
|
|
215
227
|
|
|
216
228
|
// PNG ファイル保存(savePng: true の場合のみ)
|
|
217
|
-
if (savePng && svg) {
|
|
229
|
+
if (savePng && svg && resolvedOutputDir) {
|
|
218
230
|
try {
|
|
219
|
-
ensureOutputDir(
|
|
231
|
+
ensureOutputDir(resolvedOutputDir);
|
|
220
232
|
const filename = generateBacktestChartFilename(pair, timeframe, strategyConfig.type, 'png');
|
|
221
|
-
const pngPath = join(
|
|
233
|
+
const pngPath = join(resolvedOutputDir, filename);
|
|
222
234
|
await svgToPng(svg, pngPath, { density: 150 });
|
|
223
235
|
output.chartPath = pngPath;
|
|
224
236
|
} catch (pngError) {
|