@llmtune/cli 0.1.8 → 0.1.9

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.
@@ -1,8 +1,118 @@
1
1
  "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
2
35
  Object.defineProperty(exports, "__esModule", { value: true });
3
36
  exports.getContext = getContext;
37
+ const fs = __importStar(require("fs"));
38
+ const path = __importStar(require("path"));
39
+ const os = __importStar(require("os"));
40
+ const crypto = __importStar(require("crypto"));
4
41
  const builder_1 = require("./builder");
42
+ const CACHE_DIR = path.join(os.homedir(), ".llmtune", "cache", "context");
43
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
44
+ function getCachePath(hash) {
45
+ return path.join(CACHE_DIR, `${hash}.json`);
46
+ }
47
+ function computeCacheKey(workspaceRoot, cwd) {
48
+ // Hash based on workspace root, cwd, and relevant file mtimes
49
+ const hasher = crypto.createHash("sha256");
50
+ hasher.update(`root:${workspaceRoot}`);
51
+ hasher.update(`cwd:${cwd}`);
52
+ // Include mtimes of instruction files
53
+ const instructionFiles = ["LLMTUNE.md", "CLAUDE.md", ".llmtune", ".claude"];
54
+ for (const name of instructionFiles) {
55
+ for (const dir of [cwd, workspaceRoot]) {
56
+ const fp = path.join(dir, name);
57
+ try {
58
+ const stat = fs.statSync(fp);
59
+ if (stat.isFile()) {
60
+ hasher.update(`${fp}:${stat.mtimeMs}`);
61
+ }
62
+ }
63
+ catch { /* skip */ }
64
+ }
65
+ }
66
+ return hasher.digest("hex").slice(0, 24);
67
+ }
5
68
  async function getContext(workspaceRoot, cwd, options) {
69
+ const useCache = options?.useCache !== false; // default true
70
+ if (useCache) {
71
+ const key = computeCacheKey(workspaceRoot, cwd);
72
+ const cachePath = getCachePath(key);
73
+ try {
74
+ const stat = fs.statSync(cachePath);
75
+ const age = Date.now() - stat.mtimeMs;
76
+ if (age < CACHE_TTL_MS) {
77
+ const cached = JSON.parse(fs.readFileSync(cachePath, "utf-8"));
78
+ return cached;
79
+ }
80
+ }
81
+ catch { /* cache miss */ }
82
+ // Build fresh context and cache it
83
+ const result = await (0, builder_1.buildContextPrompt)(workspaceRoot, cwd, options);
84
+ try {
85
+ if (!fs.existsSync(CACHE_DIR)) {
86
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
87
+ }
88
+ fs.writeFileSync(cachePath, JSON.stringify(result), "utf-8");
89
+ // Prune old cache entries (keep last 20)
90
+ pruneCache();
91
+ }
92
+ catch { /* cache write failure is non-critical */ }
93
+ return result;
94
+ }
6
95
  return (0, builder_1.buildContextPrompt)(workspaceRoot, cwd, options);
7
96
  }
97
+ function pruneCache(maxEntries = 20) {
98
+ try {
99
+ const entries = fs.readdirSync(CACHE_DIR)
100
+ .filter(f => f.endsWith(".json"))
101
+ .map(f => ({
102
+ name: f,
103
+ path: path.join(CACHE_DIR, f),
104
+ mtime: fs.statSync(path.join(CACHE_DIR, f)).mtimeMs,
105
+ }))
106
+ .sort((a, b) => b.mtime - a.mtime);
107
+ if (entries.length > maxEntries) {
108
+ for (const entry of entries.slice(maxEntries)) {
109
+ try {
110
+ fs.unlinkSync(entry.path);
111
+ }
112
+ catch { /* skip */ }
113
+ }
114
+ }
115
+ }
116
+ catch { /* skip */ }
117
+ }
8
118
  //# sourceMappingURL=cache.js.map
@@ -97,7 +97,8 @@ async function walkDir(root, relDir, visitor, depth = 0) {
97
97
  continue;
98
98
  const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
99
99
  const isDir = entry.isDirectory();
100
- await visitor(entry.name, relDir, isDir);
100
+ // FIX: was passing relDir instead of relPath — key file detection was broken
101
+ await visitor(entry.name, relPath, isDir);
101
102
  if (isDir && depth < 3) {
102
103
  await walkDir(root, relPath, visitor, depth + 1);
103
104
  }
package/dist/index.js CHANGED
@@ -43,11 +43,22 @@ const config_1 = require("./auth/config");
43
43
  const client_1 = require("./auth/client");
44
44
  const repl_1 = require("./repl/repl");
45
45
  const version_1 = require("./version");
46
+ const banner_1 = require("./ui/banner");
46
47
  const program = new commander_1.Command();
47
48
  program
48
49
  .name("llmtune")
49
50
  .description("AI CLI Agent powered by llmtune.io")
50
51
  .version(version_1.CLI_VERSION);
52
+ // Default action: show banner + help when no subcommand
53
+ program.action(() => {
54
+ console.log((0, banner_1.renderBanner)());
55
+ console.log(chalk_1.default.dim(" Quick start:"));
56
+ console.log(chalk_1.default.dim(` ${chalk_1.default.bold("llmtune login")} Configure your API key`));
57
+ console.log(chalk_1.default.dim(` ${chalk_1.default.bold("llmtune chat")} Start an interactive session`));
58
+ console.log(chalk_1.default.dim(` ${chalk_1.default.bold("llmtune doctor")} Diagnose your setup`));
59
+ console.log("");
60
+ program.outputHelp();
61
+ });
51
62
  program
52
63
  .command("login")
53
64
  .description("Configure API key and settings")
@@ -60,6 +71,7 @@ program
60
71
  .description("Start interactive coding session")
61
72
  .option("-m, --model <model>", "Model to use")
62
73
  .option("--no-stream", "Disable streaming")
74
+ .option("--resume", "Resume most recent session")
63
75
  .action(async (options) => {
64
76
  if (!(0, config_1.isLoggedIn)()) {
65
77
  console.log(chalk_1.default.red('Not logged in. Run "llmtune login" first.'));
@@ -69,11 +81,23 @@ program
69
81
  const client = (0, client_1.createClient)();
70
82
  const model = options.model ?? config.defaultModel ?? "z-ai/GLM-5.1";
71
83
  const apiBase = config.apiBase ?? "https://api.llmtune.io/api/agent/v1";
72
- console.log(chalk_1.default.cyan("\nLLMTune CLI"));
73
- console.log(chalk_1.default.dim(`Connected to ${apiBase}`));
74
- console.log(chalk_1.default.dim(`Model: ${model}`));
75
- console.log(chalk_1.default.dim("Type /help for commands, /exit to quit\n"));
76
- await (0, repl_1.startRepl)({ client, model, stream: options.stream !== false });
84
+ // Render the LLMTune ASCII art banner
85
+ console.log((0, banner_1.renderBanner)());
86
+ // Connection info — only here, not duplicated in startRepl
87
+ console.log(chalk_1.default.dim(` Connected to ${apiBase}`));
88
+ console.log(chalk_1.default.dim(` Model: ${model}`));
89
+ console.log(chalk_1.default.dim(` Type /help for commands, /exit to quit`));
90
+ console.log("");
91
+ // Quietly check for updates on startup
92
+ try {
93
+ const { checkForUpdate } = await Promise.resolve().then(() => __importStar(require("./commands/update")));
94
+ const latest = checkForUpdate();
95
+ if (latest) {
96
+ console.log(chalk_1.default.dim(` 📦 Update available: v${version_1.CLI_VERSION} → v${latest}. Run: llmtune update\n`));
97
+ }
98
+ }
99
+ catch { /* non-critical */ }
100
+ await (0, repl_1.startRepl)({ client, model, stream: options.stream !== false, resume: options.resume });
77
101
  });
78
102
  program
79
103
  .command("models")
@@ -89,6 +113,34 @@ program
89
113
  const { showConfig } = await Promise.resolve().then(() => __importStar(require("./commands/config")));
90
114
  showConfig();
91
115
  });
116
+ program
117
+ .command("balance")
118
+ .description("Show account balance and usage")
119
+ .action(async () => {
120
+ const { balanceCommand } = await Promise.resolve().then(() => __importStar(require("./commands/balance")));
121
+ await balanceCommand();
122
+ });
123
+ program
124
+ .command("doctor")
125
+ .description("Diagnose CLI setup and configuration")
126
+ .action(async () => {
127
+ const { doctorCommand } = await Promise.resolve().then(() => __importStar(require("./commands/doctor")));
128
+ await doctorCommand();
129
+ });
130
+ program
131
+ .command("update")
132
+ .description("Check for and install CLI updates")
133
+ .action(async () => {
134
+ const { updateCommand } = await Promise.resolve().then(() => __importStar(require("./commands/update")));
135
+ await updateCommand();
136
+ });
137
+ program
138
+ .command("logout")
139
+ .description("Remove saved API key")
140
+ .action(() => {
141
+ (0, config_1.logout)();
142
+ console.log(chalk_1.default.green("✓ Logged out successfully."));
143
+ });
92
144
  // Skills marketplace commands
93
145
  const skillsCmd = program.command("skills").description("Manage skills (list, install, publish, sign)");
94
146
  skillsCmd
@@ -3,6 +3,7 @@ export interface ReplOptions {
3
3
  client: OpenAI;
4
4
  model: string;
5
5
  stream: boolean;
6
+ resume?: boolean;
6
7
  }
7
8
  export declare function startRepl(options: ReplOptions): Promise<void>;
8
9
  //# sourceMappingURL=repl.d.ts.map
package/dist/repl/repl.js CHANGED
@@ -60,28 +60,28 @@ const trust_1 = require("../skills/trust");
60
60
  const service_2 = require("../memory/service");
61
61
  const logger_1 = require("../telemetry/logger");
62
62
  const config_1 = require("../auth/config");
63
- const version_1 = require("../version");
64
63
  const fs = __importStar(require("fs"));
65
64
  const path = __importStar(require("path"));
66
- const HELP_TEXT = `
67
- ${chalk_1.default.bold("LLMTune CLI - Commands:")}
68
-
69
- ${chalk_1.default.cyan("/help")} Show this help
70
- ${chalk_1.default.cyan("/clear")} Clear conversation history
71
- ${chalk_1.default.cyan("/context")} Show detailed context usage breakdown
72
- ${chalk_1.default.cyan("/compact")} Compact conversation (LLM summary)
73
- ${chalk_1.default.cyan("/uncompact")} Restore conversation from before compaction
74
- ${chalk_1.default.cyan("/model <name>")} Switch model
75
- ${chalk_1.default.cyan("/stream")} Toggle streaming mode
76
- ${chalk_1.default.cyan("/verbose")} Toggle verbose tool output
77
- ${chalk_1.default.cyan("/trust <tool>")} Trust a tool (skip confirmations)
78
- ${chalk_1.default.cyan("/skills")} List available skills
79
- ${chalk_1.default.cyan("/memory")} Show memory contents
80
- ${chalk_1.default.cyan("/save")} Save conversation to file
81
- ${chalk_1.default.cyan("/exit")} Exit REPL
82
-
83
- ${chalk_1.default.gray("Type your message and press Enter to chat.")}
84
- ${chalk_1.default.gray("Multi-line: end a line with '\\' to continue.")}
65
+ const HELP_TEXT = `
66
+ ${chalk_1.default.bold("LLMTune CLI - Commands:")}
67
+
68
+ ${chalk_1.default.cyan("/help")} Show this help
69
+ ${chalk_1.default.cyan("/clear")} Clear conversation history
70
+ ${chalk_1.default.cyan("/context")} Show detailed context usage breakdown
71
+ ${chalk_1.default.cyan("/compact")} Compact conversation (LLM summary)
72
+ ${chalk_1.default.cyan("/uncompact")} Restore conversation from before compaction
73
+ ${chalk_1.default.cyan("/model <name>")} Switch model
74
+ ${chalk_1.default.cyan("/stream")} Toggle streaming mode
75
+ ${chalk_1.default.cyan("/verbose")} Toggle verbose tool output
76
+ ${chalk_1.default.cyan("/trust <tool>")} Trust a tool (skip confirmations)
77
+ ${chalk_1.default.cyan("/skills")} List available skills
78
+ ${chalk_1.default.cyan("/memory")} Show memory contents
79
+ ${chalk_1.default.cyan("/balance")} Show account balance
80
+ ${chalk_1.default.cyan("/save")} Save conversation to file
81
+ ${chalk_1.default.cyan("/exit")} Exit REPL
82
+
83
+ ${chalk_1.default.gray("Type your message and press Enter to chat.")}
84
+ ${chalk_1.default.gray("Multi-line: end a line with '\\' to continue.")}
85
85
  `.trim();
86
86
  async function startRepl(options) {
87
87
  const registry = new registry_1.ToolRegistry();
@@ -110,13 +110,12 @@ async function startRepl(options) {
110
110
  let currentModel = options.model;
111
111
  let streamMode = options.stream;
112
112
  let verbose = false;
113
- console.log(chalk_1.default.cyan(`\nLLMTune CLI v${version_1.CLI_VERSION}`));
114
- console.log(chalk_1.default.dim(`Model: ${currentModel}`));
115
- console.log(chalk_1.default.dim(`Tools: ${registry.listSpecs().map((s) => s.name).join(", ")}`));
113
+ // Show tools & skills only (banner/version/model already printed by chat command)
114
+ console.log(chalk_1.default.dim(` Tools: ${registry.listSpecs().map((s) => s.name).join(", ")}`));
116
115
  if (skillList.length > 0) {
117
- console.log(chalk_1.default.dim(`Skills: ${skillList.map((s) => s.name).join(", ")}`));
116
+ console.log(chalk_1.default.dim(` Skills: ${skillList.map((s) => s.name).join(", ")}`));
118
117
  }
119
- console.log(chalk_1.default.dim(`Type /help for commands, /exit to quit.\n`));
118
+ console.log("");
120
119
  const rl = (0, readline_1.createInterface)({
121
120
  input: process.stdin,
122
121
  output: process.stdout,
@@ -138,7 +137,7 @@ async function startRepl(options) {
138
137
  return;
139
138
  }
140
139
  // Check for skill execution: /skill-name [args]
141
- if (fullInput.startsWith("/") && !fullInput.startsWith("/help") && !fullInput.startsWith("/exit") && !fullInput.startsWith("/quit") && !fullInput.startsWith("/clear") && !fullInput.startsWith("/context") && !fullInput.startsWith("/compact") && !fullInput.startsWith("/uncompact") && !fullInput.startsWith("/model") && !fullInput.startsWith("/stream") && !fullInput.startsWith("/verbose") && !fullInput.startsWith("/trust") && !fullInput.startsWith("/skills") && !fullInput.startsWith("/memory") && !fullInput.startsWith("/save")) {
140
+ if (fullInput.startsWith("/") && !fullInput.startsWith("/help") && !fullInput.startsWith("/exit") && !fullInput.startsWith("/quit") && !fullInput.startsWith("/clear") && !fullInput.startsWith("/context") && !fullInput.startsWith("/compact") && !fullInput.startsWith("/uncompact") && !fullInput.startsWith("/model") && !fullInput.startsWith("/stream") && !fullInput.startsWith("/verbose") && !fullInput.startsWith("/trust") && !fullInput.startsWith("/skills") && !fullInput.startsWith("/memory") && !fullInput.startsWith("/save") && !fullInput.startsWith("/balance")) {
142
141
  const parts = fullInput.slice(1).split(/\s+/);
143
142
  const skillName = parts[0];
144
143
  const skillArgs = parts.slice(1);
@@ -203,6 +202,7 @@ async function startRepl(options) {
203
202
  }
204
203
  // Normal chat
205
204
  try {
205
+ const startTime = Date.now();
206
206
  const result = await (0, loop_1.runAgentLoop)(options.client, conversation, registry, fullInput, {
207
207
  model: currentModel,
208
208
  maxTurns: 50,
@@ -214,17 +214,20 @@ async function startRepl(options) {
214
214
  });
215
215
  if (result.totalTokensIn > 0 || result.totalTokensOut > 0) {
216
216
  const cost = estimateCostFromUsage(result.totalTokensIn, result.totalTokensOut);
217
- console.log(chalk_1.default.dim(` [${result.turns} turn${result.turns !== 1 ? "s" : ""} | ` +
218
- `${result.totalToolCalls} tool calls | ` +
219
- `${result.totalTokensIn + result.totalTokensOut} tokens | ` +
220
- `~$${cost.toFixed(4)}]`));
217
+ const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
218
+ const costStr = cost < 0.001 ? `<$0.001` : `~$${cost.toFixed(4)}`;
219
+ console.log(chalk_1.default.dim(` [${elapsed}s | ` +
220
+ `${result.turns} turn${result.turns !== 1 ? "s" : ""} | ` +
221
+ `${result.totalToolCalls} tool${result.totalToolCalls !== 1 ? "s" : ""} | ` +
222
+ `${result.totalTokensIn + result.totalTokensOut} tok | ` +
223
+ `${costStr}]`));
221
224
  (0, logger_1.logEvent)({
222
225
  event: "llm_response",
223
226
  model: currentModel,
224
227
  tokens_in: result.totalTokensIn,
225
228
  tokens_out: result.totalTokensOut,
226
229
  cost,
227
- latency_ms: 0,
230
+ latency_ms: Date.now() - startTime,
228
231
  });
229
232
  }
230
233
  }
@@ -257,10 +260,12 @@ async function handleCommand(input, ctx) {
257
260
  case "/q":
258
261
  console.log(chalk_1.default.dim("Goodbye!"));
259
262
  process.exit(0);
260
- case "/clear":
261
- ctx.conversation.messages.length = 0;
263
+ case "/clear": {
264
+ // Use splice to properly clear messages array
265
+ ctx.conversation.messages.splice(0, ctx.conversation.messages.length);
262
266
  console.log(chalk_1.default.green("Conversation cleared."));
263
267
  break;
268
+ }
264
269
  case "/context": {
265
270
  const ctxResult = await (0, builder_1.buildContextPrompt)(ctx.cwd, ctx.cwd, { model: ctx.getModel() });
266
271
  const analysis = (0, analyzer_1.analyzeContextUsage)({
@@ -368,8 +373,20 @@ async function handleCommand(input, ctx) {
368
373
  }
369
374
  break;
370
375
  }
376
+ case "/balance": {
377
+ try {
378
+ const { balanceCommand } = await Promise.resolve().then(() => __importStar(require("../commands/balance")));
379
+ await balanceCommand();
380
+ }
381
+ catch (err) {
382
+ const msg = err instanceof Error ? err.message : String(err);
383
+ console.log(chalk_1.default.red(`Failed to fetch balance: ${msg}`));
384
+ }
385
+ break;
386
+ }
371
387
  case "/save": {
372
- const savePath = path.join(process.cwd(), `llmtune-session-${Date.now()}.json`);
388
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-").slice(0, 19);
389
+ const savePath = path.join(process.cwd(), `llmtune-session-${timestamp}.json`);
373
390
  fs.writeFileSync(savePath, JSON.stringify(ctx.conversation.getApiMessages(), null, 2), "utf-8");
374
391
  console.log(chalk_1.default.green(`Session saved to: ${savePath}`));
375
392
  break;
@@ -15,6 +15,8 @@ export declare class PermissionManager {
15
15
  constructor();
16
16
  trustTool(toolName: string): void;
17
17
  isTrusted(toolName: string): boolean;
18
+ /** Get list of all trusted tool names for this session */
19
+ trustedTools(): string[];
18
20
  check(toolName: string, input: Record<string, unknown>, isDestructive: boolean): Promise<PermissionCheckResult>;
19
21
  }
20
22
  //# sourceMappingURL=permissions.d.ts.map
@@ -21,6 +21,15 @@ class PermissionManager {
21
21
  isTrusted(toolName) {
22
22
  return this.config.sessionTrust.get(toolName) === true;
23
23
  }
24
+ /** Get list of all trusted tool names for this session */
25
+ trustedTools() {
26
+ const trusted = [];
27
+ for (const [name, isTrusted] of this.config.sessionTrust) {
28
+ if (isTrusted)
29
+ trusted.push(name);
30
+ }
31
+ return trusted;
32
+ }
24
33
  async check(toolName, input, isDestructive) {
25
34
  if (this.isTrusted(toolName)) {
26
35
  return { behavior: "allow" };
@@ -0,0 +1,10 @@
1
+ /**
2
+ * Security: validate that a file path is within the workspace root.
3
+ * Prevents path traversal attacks (e.g. ../../etc/passwd).
4
+ */
5
+ export declare function sanitizeFilePath(filePath: string, workspaceRoot: string): string;
6
+ /**
7
+ * Validate a URL is safe for web-fetch (http/https only, no internal IPs by default).
8
+ */
9
+ export declare function sanitizeUrl(url: string): void;
10
+ //# sourceMappingURL=sanitize.d.ts.map
@@ -0,0 +1,87 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.sanitizeFilePath = sanitizeFilePath;
37
+ exports.sanitizeUrl = sanitizeUrl;
38
+ const path = __importStar(require("path"));
39
+ /**
40
+ * Security: validate that a file path is within the workspace root.
41
+ * Prevents path traversal attacks (e.g. ../../etc/passwd).
42
+ */
43
+ function sanitizeFilePath(filePath, workspaceRoot) {
44
+ const resolved = path.resolve(workspaceRoot, filePath);
45
+ // Normalize for comparison (handle .., ., symlinks)
46
+ const normalizedWorkspace = path.normalize(workspaceRoot);
47
+ const normalizedResolved = path.normalize(resolved);
48
+ if (!normalizedResolved.startsWith(normalizedWorkspace + path.sep) &&
49
+ normalizedResolved !== normalizedWorkspace) {
50
+ throw new Error(`Path traversal blocked: "${filePath}" resolves outside workspace root. ` +
51
+ `Resolved: ${normalizedResolved}, Workspace: ${normalizedWorkspace}`);
52
+ }
53
+ return resolved;
54
+ }
55
+ /**
56
+ * Validate a URL is safe for web-fetch (http/https only, no internal IPs by default).
57
+ */
58
+ function sanitizeUrl(url) {
59
+ let parsed;
60
+ try {
61
+ parsed = new URL(url);
62
+ }
63
+ catch {
64
+ throw new Error(`Invalid URL: ${url}`);
65
+ }
66
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
67
+ throw new Error(`Only http:// and https:// URLs are allowed. Got: ${parsed.protocol}`);
68
+ }
69
+ // Block internal IPs (basic SSRF protection)
70
+ const hostname = parsed.hostname;
71
+ const internalPatterns = [
72
+ /^127\./,
73
+ /^10\./,
74
+ /^172\.(1[6-9]|2\d|3[01])\./,
75
+ /^192\.168\./,
76
+ /^0\./,
77
+ /^localhost$/i,
78
+ /^::1$/,
79
+ /^fd/i,
80
+ ];
81
+ for (const pattern of internalPatterns) {
82
+ if (pattern.test(hostname)) {
83
+ throw new Error(`Internal/private network addresses are not allowed: ${hostname}`);
84
+ }
85
+ }
86
+ }
87
+ //# sourceMappingURL=sanitize.js.map
@@ -35,7 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.editTool = void 0;
37
37
  const fs = __importStar(require("fs/promises"));
38
- const path = __importStar(require("path"));
38
+ const sanitize_1 = require("../sanitize");
39
39
  exports.editTool = {
40
40
  spec() {
41
41
  return {
@@ -78,7 +78,13 @@ exports.editTool = {
78
78
  if (!oldString) {
79
79
  return { name: "edit", output: { error: "old_string is required" }, isError: true };
80
80
  }
81
- const resolved = path.resolve(ctx.cwd, filePath);
81
+ let resolved;
82
+ try {
83
+ resolved = (0, sanitize_1.sanitizeFilePath)(filePath, ctx.workspaceRoot);
84
+ }
85
+ catch (err) {
86
+ return { name: "edit", output: { error: err.message }, isError: true };
87
+ }
82
88
  let content;
83
89
  try {
84
90
  content = await fs.readFile(resolved, "utf-8");
@@ -86,7 +86,7 @@ exports.grepTool = {
86
86
  const includeOpt = include ? ` --include="${include}"` : "";
87
87
  cmd = `grep -r -n -E${includeOpt} "${pattern.replace(/"/g, "")}" "${searchPath}" 2>/dev/null | head -${maxResults}`;
88
88
  }
89
- const result = execSync(cmd, { maxBuffer: 1024 * 1024, timeout: 30000 });
89
+ const result = runGrepExec(cmd, { maxBuffer: 1024 * 1024, timeout: 30000 });
90
90
  const output = result.toString().trim();
91
91
  const lines = output.split("\n").filter(Boolean);
92
92
  const matches = lines.slice(0, maxResults).map((line) => {
@@ -141,7 +141,7 @@ exports.grepTool = {
141
141
  }
142
142
  },
143
143
  };
144
- function execSync(cmd, opts) {
144
+ function runGrepExec(cmd, opts) {
145
145
  const { execSync: _execSync } = require("child_process");
146
146
  return _execSync(cmd, opts);
147
147
  }
@@ -35,7 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.readTool = void 0;
37
37
  const fs = __importStar(require("fs"));
38
- const path = __importStar(require("path"));
38
+ const sanitize_1 = require("../sanitize");
39
39
  exports.readTool = {
40
40
  spec() {
41
41
  return {
@@ -54,9 +54,12 @@ exports.readTool = {
54
54
  };
55
55
  },
56
56
  run(input, ctx) {
57
- const filePath = path.resolve(ctx.cwd, String(input.file_path ?? ""));
58
- if (!filePath.startsWith(ctx.cwd)) {
59
- return { name: "read", output: { error: "Path is outside the workspace" }, isError: true };
57
+ let filePath;
58
+ try {
59
+ filePath = (0, sanitize_1.sanitizeFilePath)(String(input.file_path ?? ""), ctx.workspaceRoot);
60
+ }
61
+ catch (err) {
62
+ return { name: "read", output: { error: err.message }, isError: true };
60
63
  }
61
64
  if (!fs.existsSync(filePath)) {
62
65
  return { name: "read", output: { error: `File not found: ${input.file_path}` }, isError: true };