@llmtune/cli 0.1.0

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.
Files changed (102) hide show
  1. package/README.md +181 -0
  2. package/dist/agent/conversation.d.ts.map +1 -0
  3. package/dist/agent/loop.d.ts.map +1 -0
  4. package/dist/agent/planner.d.ts.map +1 -0
  5. package/dist/auth/client.d.ts.map +1 -0
  6. package/dist/auth/config.d.ts.map +1 -0
  7. package/dist/commands/chat.d.ts.map +1 -0
  8. package/dist/commands/config.d.ts.map +1 -0
  9. package/dist/commands/login.d.ts.map +1 -0
  10. package/dist/commands/marketplace.d.ts.map +1 -0
  11. package/dist/commands/models.d.ts.map +1 -0
  12. package/dist/compact/history-store.d.ts.map +1 -0
  13. package/dist/compact/microcompact.d.ts.map +1 -0
  14. package/dist/compact/service.d.ts.map +1 -0
  15. package/dist/context/analyzer.d.ts.map +1 -0
  16. package/dist/context/builder.d.ts.map +1 -0
  17. package/dist/context/cache.d.ts.map +1 -0
  18. package/dist/context/git-context.d.ts.map +1 -0
  19. package/dist/context/llmtune-md.d.ts.map +1 -0
  20. package/dist/context/workspace.d.ts.map +1 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +125 -0
  23. package/dist/marketplace/client.d.ts.map +1 -0
  24. package/dist/memory/files.d.ts.map +1 -0
  25. package/dist/memory/service.d.ts.map +1 -0
  26. package/dist/repl/repl.d.ts.map +1 -0
  27. package/dist/skills/args.d.ts.map +1 -0
  28. package/dist/skills/frontmatter.d.ts.map +1 -0
  29. package/dist/skills/loader.d.ts.map +1 -0
  30. package/dist/skills/registry.d.ts.map +1 -0
  31. package/dist/skills/signing/signer.d.ts.map +1 -0
  32. package/dist/skills/trust.d.ts.map +1 -0
  33. package/dist/telemetry/logger.d.ts.map +1 -0
  34. package/dist/tools/permissions.d.ts.map +1 -0
  35. package/dist/tools/protocol.d.ts.map +1 -0
  36. package/dist/tools/registry.d.ts.map +1 -0
  37. package/dist/tools/sandbox/docker.d.ts.map +1 -0
  38. package/dist/tools/sandbox/index.d.ts.map +1 -0
  39. package/dist/tools/tools/ask-user.d.ts.map +1 -0
  40. package/dist/tools/tools/bash.d.ts.map +1 -0
  41. package/dist/tools/tools/edit.d.ts.map +1 -0
  42. package/dist/tools/tools/glob.d.ts.map +1 -0
  43. package/dist/tools/tools/grep.d.ts.map +1 -0
  44. package/dist/tools/tools/read.d.ts.map +1 -0
  45. package/dist/tools/tools/web-fetch.d.ts.map +1 -0
  46. package/dist/tools/tools/write.d.ts.map +1 -0
  47. package/dist/tools/validation.d.ts.map +1 -0
  48. package/dist/utils/markdown.d.ts.map +1 -0
  49. package/dist/utils/streaming.d.ts.map +1 -0
  50. package/dist/utils/tokens.d.ts.map +1 -0
  51. package/docs/SKILL_AUTHORING.md +175 -0
  52. package/package.json +38 -0
  53. package/src/agent/conversation.ts +140 -0
  54. package/src/agent/loop.ts +215 -0
  55. package/src/agent/planner.ts +55 -0
  56. package/src/auth/client.ts +19 -0
  57. package/src/auth/config.ts +89 -0
  58. package/src/commands/chat.ts +28 -0
  59. package/src/commands/config.ts +36 -0
  60. package/src/commands/login.ts +63 -0
  61. package/src/commands/marketplace.ts +190 -0
  62. package/src/commands/models.ts +74 -0
  63. package/src/compact/history-store.ts +101 -0
  64. package/src/compact/microcompact.ts +49 -0
  65. package/src/compact/service.ts +154 -0
  66. package/src/context/analyzer.ts +127 -0
  67. package/src/context/builder.ts +123 -0
  68. package/src/context/cache.ts +11 -0
  69. package/src/context/git-context.ts +58 -0
  70. package/src/context/llmtune-md.ts +48 -0
  71. package/src/context/workspace.ts +139 -0
  72. package/src/index.ts +100 -0
  73. package/src/marketplace/client.ts +118 -0
  74. package/src/memory/files.ts +81 -0
  75. package/src/memory/service.ts +124 -0
  76. package/src/repl/repl.ts +400 -0
  77. package/src/skills/args.ts +35 -0
  78. package/src/skills/builtin/explain-code/SKILL.md +30 -0
  79. package/src/skills/frontmatter.ts +47 -0
  80. package/src/skills/loader.ts +25 -0
  81. package/src/skills/registry.ts +155 -0
  82. package/src/skills/signing/signer.ts +101 -0
  83. package/src/skills/trust.ts +50 -0
  84. package/src/telemetry/logger.ts +108 -0
  85. package/src/tools/permissions.ts +83 -0
  86. package/src/tools/protocol.ts +24 -0
  87. package/src/tools/registry.ts +93 -0
  88. package/src/tools/sandbox/docker.ts +225 -0
  89. package/src/tools/sandbox/index.ts +91 -0
  90. package/src/tools/tools/ask-user.ts +60 -0
  91. package/src/tools/tools/bash.ts +97 -0
  92. package/src/tools/tools/edit.ts +111 -0
  93. package/src/tools/tools/glob.ts +68 -0
  94. package/src/tools/tools/grep.ts +121 -0
  95. package/src/tools/tools/read.ts +57 -0
  96. package/src/tools/tools/web-fetch.ts +158 -0
  97. package/src/tools/tools/write.ts +52 -0
  98. package/src/tools/validation.ts +164 -0
  99. package/src/utils/markdown.ts +96 -0
  100. package/src/utils/streaming.ts +63 -0
  101. package/src/utils/tokens.ts +41 -0
  102. package/tsconfig.json +20 -0
@@ -0,0 +1,101 @@
1
+ import * as fs from "fs"
2
+ import * as path from "path"
3
+ import * as crypto from "crypto"
4
+
5
+ export interface SkillSignature {
6
+ algorithm: string
7
+ signature: string
8
+ publicKey: string
9
+ signedAt: string
10
+ skillName: string
11
+ contentHash: string
12
+ }
13
+
14
+ const SIGNATURE_FILE = "SIGNATURE.json"
15
+
16
+ export function hashSkillContent(skillDir: string): string {
17
+ const mdPath = path.join(skillDir, "SKILL.md")
18
+ if (!fs.existsSync(mdPath)) {
19
+ throw new Error(`No SKILL.md found in ${skillDir}`)
20
+ }
21
+ const content = fs.readFileSync(mdPath, "utf-8")
22
+ return crypto.createHash("sha256").update(content).digest("hex")
23
+ }
24
+
25
+ export function signSkill(
26
+ skillDir: string,
27
+ privateKey: string,
28
+ publicKey: string,
29
+ ): SkillSignature {
30
+ const contentHash = hashSkillContent(skillDir)
31
+ const skillName = path.basename(skillDir)
32
+
33
+ const signature = crypto
34
+ .createSign("sha256")
35
+ .update(contentHash)
36
+ .sign(privateKey, "base64")
37
+
38
+ const sig: SkillSignature = {
39
+ algorithm: "sha256-rsa",
40
+ signature,
41
+ publicKey,
42
+ signedAt: new Date().toISOString(),
43
+ skillName,
44
+ contentHash,
45
+ }
46
+
47
+ const sigPath = path.join(skillDir, SIGNATURE_FILE)
48
+ fs.writeFileSync(sigPath, JSON.stringify(sig, null, 2), "utf-8")
49
+
50
+ return sig
51
+ }
52
+
53
+ export function verifySkill(
54
+ skillDir: string,
55
+ expectedPublicKey?: string,
56
+ ): { valid: boolean; reason?: string } {
57
+ const sigPath = path.join(skillDir, SIGNATURE_FILE)
58
+ const mdPath = path.join(skillDir, "SKILL.md")
59
+
60
+ if (!fs.existsSync(sigPath)) {
61
+ return { valid: false, reason: "No signature file found" }
62
+ }
63
+ if (!fs.existsSync(mdPath)) {
64
+ return { valid: false, reason: "No SKILL.md found" }
65
+ }
66
+
67
+ try {
68
+ const sig: SkillSignature = JSON.parse(fs.readFileSync(sigPath, "utf-8"))
69
+ const content = fs.readFileSync(mdPath, "utf-8")
70
+ const contentHash = crypto.createHash("sha256").update(content).digest("hex")
71
+
72
+ if (contentHash !== sig.contentHash) {
73
+ return { valid: false, reason: "Content hash mismatch - skill has been modified" }
74
+ }
75
+
76
+ if (expectedPublicKey && sig.publicKey !== expectedPublicKey) {
77
+ return { valid: false, reason: "Public key mismatch - signed by different author" }
78
+ }
79
+
80
+ const verifier = crypto.createVerify("sha256")
81
+ verifier.update(sig.contentHash)
82
+ const isValid = verifier.verify(sig.publicKey, sig.signature, "base64")
83
+
84
+ if (!isValid) {
85
+ return { valid: false, reason: "Signature verification failed" }
86
+ }
87
+
88
+ return { valid: true }
89
+ } catch (err) {
90
+ return { valid: false, reason: `Verification error: ${(err as Error).message}` }
91
+ }
92
+ }
93
+
94
+ export function generateKeyPair(): { publicKey: string; privateKey: string } {
95
+ const { publicKey, privateKey } = crypto.generateKeyPairSync("rsa", {
96
+ modulusLength: 2048,
97
+ publicKeyEncoding: { type: "spki", format: "pem" },
98
+ privateKeyEncoding: { type: "pkcs8", format: "pem" },
99
+ })
100
+ return { publicKey, privateKey }
101
+ }
@@ -0,0 +1,50 @@
1
+ import type { Skill } from "./registry"
2
+
3
+ export type TrustLevel = "local" | "community" | "verified" | "signed"
4
+
5
+ export interface TrustPolicy {
6
+ allowedTools: string[]
7
+ canExecuteBash: boolean
8
+ canWriteFiles: boolean
9
+ }
10
+
11
+ const POLICIES: Record<TrustLevel, TrustPolicy> = {
12
+ local: {
13
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
14
+ canExecuteBash: true,
15
+ canWriteFiles: true,
16
+ },
17
+ community: {
18
+ allowedTools: ["read", "glob", "grep"],
19
+ canExecuteBash: false,
20
+ canWriteFiles: false,
21
+ },
22
+ verified: {
23
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
24
+ canExecuteBash: true,
25
+ canWriteFiles: true,
26
+ },
27
+ signed: {
28
+ allowedTools: ["read", "write", "edit", "bash", "glob", "grep"],
29
+ canExecuteBash: true,
30
+ canWriteFiles: true,
31
+ },
32
+ }
33
+
34
+ export function getTrustPolicy(level: TrustLevel): TrustPolicy {
35
+ return POLICIES[level]
36
+ }
37
+
38
+ export function resolveTrustLevel(skill: Skill): TrustLevel {
39
+ if (skill.loadedFrom === "project" || skill.loadedFrom === "user") return "local"
40
+ if (skill.trustLevel) return skill.trustLevel
41
+ return "community"
42
+ }
43
+
44
+ export function isToolAllowedForSkill(
45
+ toolName: string,
46
+ trustLevel: TrustLevel
47
+ ): boolean {
48
+ const policy = POLICIES[trustLevel]
49
+ return policy.allowedTools.includes(toolName.toLowerCase())
50
+ }
@@ -0,0 +1,108 @@
1
+ import * as fs from "fs"
2
+ import * as path from "path"
3
+ import * as os from "os"
4
+ import { getConfigDir } from "../auth/config"
5
+
6
+ export type TelemetryEvent =
7
+ | { event: "tool_call"; tool: string; latency_ms: number; input_preview?: string }
8
+ | { event: "llm_response"; tokens_in: number; tokens_out: number; cost: number; model: string; latency_ms: number }
9
+ | { event: "compaction"; tokens_saved: number; messages_before: number; messages_after: number; trigger: string }
10
+ | { event: "error"; source: string; error: string; tool?: string }
11
+ | { event: "session_start"; model: string; tools: string[]; cwd: string }
12
+ | { event: "session_end"; duration_ms: number; total_tool_calls: number; total_tokens: number }
13
+
14
+ interface SessionLog {
15
+ sessionId: string
16
+ startedAt: string
17
+ events: TelemetryEvent[]
18
+ }
19
+
20
+ let currentSession: SessionLog | null = null
21
+ let sessionStartTime = 0
22
+
23
+ function getLogsDir(): string {
24
+ const base = process.env.LLMTUNE_LOGS_DIR || path.join(getConfigDir(), "logs")
25
+ if (!fs.existsSync(base)) {
26
+ fs.mkdirSync(base, { recursive: true })
27
+ }
28
+ return base
29
+ }
30
+
31
+ function getSessionLogPath(sessionId: string): string {
32
+ return path.join(getLogsDir(), `${sessionId}.jsonl`)
33
+ }
34
+
35
+ export function startSessionLog(sessionId: string, meta: { model: string; tools: string[]; cwd: string }): void {
36
+ currentSession = {
37
+ sessionId,
38
+ startedAt: new Date().toISOString(),
39
+ events: [],
40
+ }
41
+ sessionStartTime = Date.now()
42
+ logEvent({ event: "session_start", model: meta.model, tools: meta.tools, cwd: meta.cwd })
43
+ }
44
+
45
+ export function logEvent(event: TelemetryEvent): void {
46
+ if (!currentSession) return
47
+ currentSession.events.push(event)
48
+
49
+ const entry = {
50
+ ts: new Date().toISOString(),
51
+ session_id: currentSession.sessionId,
52
+ ...event,
53
+ }
54
+
55
+ try {
56
+ const logPath = getSessionLogPath(currentSession.sessionId)
57
+ fs.appendFileSync(logPath, JSON.stringify(entry) + "\n", "utf-8")
58
+ } catch {
59
+ // Telemetry write failure is non-critical
60
+ }
61
+ }
62
+
63
+ export function endSessionLog(stats: { totalToolCalls: number; totalTokens: number }): void {
64
+ if (!currentSession) return
65
+ const durationMs = Date.now() - sessionStartTime
66
+ logEvent({
67
+ event: "session_end",
68
+ duration_ms: durationMs,
69
+ total_tool_calls: stats.totalToolCalls,
70
+ total_tokens: stats.totalTokens,
71
+ })
72
+ currentSession = null
73
+ }
74
+
75
+ export function getSessionLogs(sessionId: string): TelemetryEvent[] {
76
+ const logPath = getSessionLogPath(sessionId)
77
+ try {
78
+ if (!fs.existsSync(logPath)) return []
79
+ const lines = fs.readFileSync(logPath, "utf-8").split("\n").filter(Boolean)
80
+ return lines.map((line) => {
81
+ try { return JSON.parse(line) as TelemetryEvent }
82
+ catch { return null }
83
+ }).filter((e): e is TelemetryEvent => e !== null)
84
+ } catch {
85
+ return []
86
+ }
87
+ }
88
+
89
+ export function listSessionLogs(): Array<{ sessionId: string; size: number; modified: string }> {
90
+ const dir = getLogsDir()
91
+ try {
92
+ if (!fs.existsSync(dir)) return []
93
+ return fs.readdirSync(dir)
94
+ .filter((f) => f.endsWith(".jsonl"))
95
+ .map((f) => {
96
+ const fullPath = path.join(dir, f)
97
+ const stat = fs.statSync(fullPath)
98
+ return {
99
+ sessionId: f.replace(".jsonl", ""),
100
+ size: stat.size,
101
+ modified: stat.mtime.toISOString(),
102
+ }
103
+ })
104
+ .sort((a, b) => b.modified.localeCompare(a.modified))
105
+ } catch {
106
+ return []
107
+ }
108
+ }
@@ -0,0 +1,83 @@
1
+ import inquirer from "@inquirer/prompts"
2
+ import chalk from "chalk"
3
+
4
+ export type PermissionBehavior = "allow" | "deny" | "ask"
5
+
6
+ export interface PermissionCheckResult {
7
+ behavior: PermissionBehavior
8
+ message?: string
9
+ suggestion?: string
10
+ updatedInput?: Record<string, unknown>
11
+ }
12
+
13
+ export interface PermissionConfig {
14
+ allowedTools: Set<string>
15
+ deniedTools: Set<string>
16
+ sessionTrust: Map<string, boolean>
17
+ }
18
+
19
+ export class PermissionManager {
20
+ private config: PermissionConfig
21
+
22
+ constructor() {
23
+ this.config = {
24
+ allowedTools: new Set(),
25
+ deniedTools: new Set(),
26
+ sessionTrust: new Map(),
27
+ }
28
+ }
29
+
30
+ trustTool(toolName: string): void {
31
+ this.config.sessionTrust.set(toolName, true)
32
+ }
33
+
34
+ isTrusted(toolName: string): boolean {
35
+ return this.config.sessionTrust.get(toolName) === true
36
+ }
37
+
38
+ async check(
39
+ toolName: string,
40
+ input: Record<string, unknown>,
41
+ isDestructive: boolean
42
+ ): Promise<PermissionCheckResult> {
43
+ if (this.isTrusted(toolName)) {
44
+ return { behavior: "allow" }
45
+ }
46
+
47
+ if (this.config.deniedTools.has(toolName)) {
48
+ return { behavior: "deny", message: `Tool '${toolName}' is denied` }
49
+ }
50
+
51
+ if (isDestructive) {
52
+ const command = toolName === "bash" ? (input.command as string) : ""
53
+ const preview = command
54
+ ? command.slice(0, 80) + (command.length > 80 ? "..." : "")
55
+ : JSON.stringify(input).slice(0, 100)
56
+
57
+ console.log(chalk.yellow(`\n⚠ ${toolName} wants to execute:`))
58
+ console.log(chalk.dim(preview))
59
+
60
+ const confirmed = await inquirer.confirm({
61
+ message: `Allow ${toolName}? (y/N)`,
62
+ default: false,
63
+ })
64
+
65
+ if (!confirmed) {
66
+ return { behavior: "deny", message: "User denied" }
67
+ }
68
+
69
+ const alwaysTrust = await inquirer.confirm({
70
+ message: `Trust ${toolName} for this session? (y/N)`,
71
+ default: false,
72
+ })
73
+
74
+ if (alwaysTrust) {
75
+ this.trustTool(toolName)
76
+ }
77
+
78
+ return { behavior: "allow" }
79
+ }
80
+
81
+ return { behavior: "allow" }
82
+ }
83
+ }
@@ -0,0 +1,24 @@
1
+ export interface ToolSpec {
2
+ name: string
3
+ description: string
4
+ inputSchema: Record<string, unknown>
5
+ isReadOnly?: boolean
6
+ isDestructive?: boolean
7
+ }
8
+
9
+ export interface ToolResult {
10
+ name: string
11
+ output: unknown
12
+ isError: boolean
13
+ toolUseId?: string
14
+ }
15
+
16
+ export interface ToolContext {
17
+ cwd: string
18
+ workspaceRoot: string
19
+ }
20
+
21
+ export interface Tool {
22
+ spec(): ToolSpec
23
+ run(input: Record<string, unknown>, ctx: ToolContext): ToolResult | Promise<ToolResult>
24
+ }
@@ -0,0 +1,93 @@
1
+ import type { Tool, ToolSpec, ToolResult, ToolContext } from "./protocol"
2
+ import { validateJsonSchema } from "./validation"
3
+
4
+ export type { Tool, ToolSpec, ToolResult, ToolContext }
5
+
6
+ export interface ToolDefinition {
7
+ name: string
8
+ description: string
9
+ inputSchema: Record<string, unknown>
10
+ isReadOnly?: boolean
11
+ isDestructive?: boolean
12
+ run: (input: Record<string, unknown>, ctx: ToolContext) => ToolResult | Promise<ToolResult>
13
+ }
14
+
15
+ class ToolInstance implements Tool {
16
+ private def: ToolDefinition
17
+ constructor(def: ToolDefinition) { this.def = def }
18
+ spec(): ToolSpec {
19
+ return {
20
+ name: this.def.name,
21
+ description: this.def.description,
22
+ inputSchema: this.def.inputSchema,
23
+ isReadOnly: this.def.isReadOnly,
24
+ isDestructive: this.def.isDestructive,
25
+ }
26
+ }
27
+ run(input: Record<string, unknown>, ctx: ToolContext): ToolResult | Promise<ToolResult> {
28
+ return this.def.run(input, ctx)
29
+ }
30
+ }
31
+
32
+ export function createTool(def: ToolDefinition): Tool {
33
+ return new ToolInstance(def)
34
+ }
35
+
36
+ export class ToolRegistry {
37
+ private tools: Map<string, Tool> = new Map()
38
+
39
+ register(tool: Tool): void {
40
+ const spec = tool.spec()
41
+ const key = spec.name.toLowerCase()
42
+ if (this.tools.has(key)) {
43
+ throw new Error(`duplicate tool name: ${spec.name}`)
44
+ }
45
+ this.tools.set(key, tool)
46
+ }
47
+
48
+ listSpecs(): ToolSpec[] {
49
+ return Array.from(this.tools.values()).map((t) => t.spec())
50
+ }
51
+
52
+ get(name: string): Tool | undefined {
53
+ return this.tools.get(name.toLowerCase())
54
+ }
55
+
56
+ dispatch(name: string, input: Record<string, unknown>, ctx: ToolContext): ToolResult {
57
+ const tool = this.get(name)
58
+ if (!tool) {
59
+ return { name, output: { error: `unknown tool: ${name}` }, isError: true }
60
+ }
61
+
62
+ const spec = tool.spec()
63
+ try {
64
+ validateJsonSchema(input, spec.inputSchema)
65
+ } catch (err: unknown) {
66
+ const msg = err instanceof Error ? err.message : String(err)
67
+ return { name: spec.name, output: { error: msg }, isError: true }
68
+ }
69
+
70
+ const result = tool.run(input, ctx)
71
+ if (result instanceof Promise) {
72
+ throw new Error("Async tools not supported in sync dispatch. Use dispatchAsync instead.")
73
+ }
74
+ return result
75
+ }
76
+
77
+ async dispatchAsync(name: string, input: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
78
+ const tool = this.get(name)
79
+ if (!tool) {
80
+ return { name, output: { error: `unknown tool: ${name}` }, isError: true }
81
+ }
82
+
83
+ const spec = tool.spec()
84
+ try {
85
+ validateJsonSchema(input, spec.inputSchema)
86
+ } catch (err: unknown) {
87
+ const msg = err instanceof Error ? err.message : String(err)
88
+ return { name: spec.name, output: { error: msg }, isError: true }
89
+ }
90
+
91
+ return tool.run(input, ctx)
92
+ }
93
+ }
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Docker-based tool sandbox for enterprise deployments.
3
+ * Isolates bash and file write operations inside a Docker container
4
+ * to prevent accidental or malicious damage to the host system.
5
+ */
6
+ import { execFile } from "child_process"
7
+ import { promisify } from "util"
8
+ import * as path from "path"
9
+ import * as fs from "fs"
10
+ import * as os from "os"
11
+ import type { ToolResult } from "../protocol"
12
+
13
+ const execFileAsync = promisify(execFile)
14
+
15
+ const SANDBOX_IMAGE = "llmtune-sandbox:latest"
16
+ const SANDBOX_WORKDIR = "/workspace"
17
+ const EXECUTION_TIMEOUT_MS = 60_000
18
+ const MAX_OUTPUT_BYTES = 100_000
19
+
20
+ export interface SandboxConfig {
21
+ enabled: boolean
22
+ image: string
23
+ timeoutMs: number
24
+ readOnlyPaths: string[]
25
+ maxMemoryMB: number
26
+ }
27
+
28
+ export const DEFAULT_SANDBOX_CONFIG: SandboxConfig = {
29
+ enabled: false,
30
+ image: SANDBOX_IMAGE,
31
+ timeoutMs: EXECUTION_TIMEOUT_MS,
32
+ readOnlyPaths: [],
33
+ maxMemoryMB: 512,
34
+ }
35
+
36
+ let sandboxConfig: SandboxConfig = { ...DEFAULT_SANDBOX_CONFIG }
37
+
38
+ export function configureSandbox(config: Partial<SandboxConfig>): void {
39
+ sandboxConfig = { ...sandboxConfig, ...config }
40
+ }
41
+
42
+ export function isSandboxEnabled(): boolean {
43
+ return sandboxConfig.enabled
44
+ }
45
+
46
+ export async function isDockerAvailable(): Promise<boolean> {
47
+ try {
48
+ const { stdout } = await execFileAsync("docker", ["--version"], {
49
+ timeout: 5000,
50
+ })
51
+ return /docker/i.test(stdout)
52
+ } catch {
53
+ return false
54
+ }
55
+ }
56
+
57
+ export async function ensureSandboxImage(): Promise<boolean> {
58
+ try {
59
+ const { stdout } = await execFileAsync("docker", ["images", "-q", sandboxConfig.image], {
60
+ timeout: 10000,
61
+ })
62
+ if (stdout.trim()) return true
63
+
64
+ // Pull the image if not found
65
+ console.log(`Pulling sandbox image: ${sandboxConfig.image}...`)
66
+ await execFileAsync("docker", ["pull", sandboxConfig.image], {
67
+ timeout: 120_000,
68
+ })
69
+ return true
70
+ } catch {
71
+ return false
72
+ }
73
+ }
74
+
75
+ export async function runInSandbox(command: string, cwd: string): Promise<ToolResult> {
76
+ if (!sandboxConfig.enabled) {
77
+ return {
78
+ name: "sandbox",
79
+ output: { error: "Sandbox is not enabled" },
80
+ isError: true,
81
+ }
82
+ }
83
+
84
+ const dockerAvailable = await isDockerAvailable()
85
+ if (!dockerAvailable) {
86
+ return {
87
+ name: "sandbox",
88
+ output: { error: "Docker is not available. Install Docker to use sandbox mode." },
89
+ isError: true,
90
+ }
91
+ }
92
+
93
+ const workspaceMount = `${path.resolve(cwd)}:${SANDBOX_WORKDIR}`
94
+ const memoryLimit = `${sandboxConfig.maxMemoryMB}m`
95
+ const timeout = Math.min(sandboxConfig.timeoutMs, 120_000)
96
+
97
+ const args = [
98
+ "run",
99
+ "--rm",
100
+ "--network", "none",
101
+ "--memory", memoryLimit,
102
+ "--cpus", "1",
103
+ "--timeout", String(Math.ceil(timeout / 1000)),
104
+ "-v", `${workspaceMount}:ro`,
105
+ "--workdir", SANDBOX_WORKDIR,
106
+ sandboxConfig.image,
107
+ "sh", "-c", command,
108
+ ]
109
+
110
+ try {
111
+ const { stdout, stderr } = await execFileAsync("docker", args, {
112
+ timeout: timeout + 10_000,
113
+ maxBuffer: MAX_OUTPUT_BYTES,
114
+ })
115
+
116
+ let output = ""
117
+ if (stdout) output += truncateOutput(stdout)
118
+ if (stderr) {
119
+ output += "\n--- stderr ---\n"
120
+ output += truncateOutput(stderr)
121
+ }
122
+
123
+ return {
124
+ name: "sandbox",
125
+ output: {
126
+ stdout: output.trim(),
127
+ exit_code: 0,
128
+ sandboxed: true,
129
+ },
130
+ isError: false,
131
+ }
132
+ } catch (err: unknown) {
133
+ const execErr = err as { stdout?: string; stderr?: string; killed?: boolean; code?: number }
134
+ let output = ""
135
+ if (execErr.stdout) output += truncateOutput(execErr.stdout)
136
+ if (execErr.stderr) {
137
+ output += "\n--- stderr ---\n"
138
+ output += truncateOutput(execErr.stderr)
139
+ }
140
+ if (execErr.killed) {
141
+ output += `\n--- Process killed (timeout or memory limit) ---`
142
+ }
143
+
144
+ return {
145
+ name: "sandbox",
146
+ output: {
147
+ stdout: output.trim(),
148
+ exit_code: typeof execErr.code === "number" ? execErr.code : 1,
149
+ sandboxed: true,
150
+ timed_out: execErr.killed ?? false,
151
+ },
152
+ isError: true,
153
+ }
154
+ }
155
+ }
156
+
157
+ export async function writeFileInSandbox(
158
+ filePath: string,
159
+ content: string,
160
+ cwd: string,
161
+ ): Promise<ToolResult> {
162
+ if (!sandboxConfig.enabled) {
163
+ return {
164
+ name: "sandbox",
165
+ output: { error: "Sandbox is not enabled" },
166
+ isError: true,
167
+ }
168
+ }
169
+
170
+ // Write to a temp file on host, then copy into container
171
+ const tmpDir = path.join(os.tmpdir(), "llmtune-sandbox")
172
+ fs.mkdirSync(tmpDir, { recursive: true })
173
+ const tmpFile = path.join(tmpDir, `write-${Date.now()}.tmp`)
174
+ fs.writeFileSync(tmpFile, content, "utf-8")
175
+
176
+ try {
177
+ const absTarget = path.resolve(cwd, filePath)
178
+ const containerPath = `${SANDBOX_WORKDIR}/${path.relative(cwd, absTarget).replace(/\\/g, "/")}`
179
+ const workspaceMount = `${path.resolve(cwd)}:${SANDBOX_WORKDIR}`
180
+
181
+ const dockerArgs = [
182
+ "run",
183
+ "--rm",
184
+ "--network", "none",
185
+ "--memory", `${sandboxConfig.maxMemoryMB}m`,
186
+ "-v", `${workspaceMount}`,
187
+ "-v", `${tmpFile}:/tmp/write-content.tmp:ro`,
188
+ "--workdir", SANDBOX_WORKDIR,
189
+ sandboxConfig.image,
190
+ "sh", "-c", `mkdir -p "$(dirname '${containerPath}')" && cp /tmp/write-content.tmp '${containerPath}'`,
191
+ ]
192
+
193
+ await execFileAsync("docker", dockerArgs, { timeout: 30_000 })
194
+
195
+ return {
196
+ name: "sandbox",
197
+ output: {
198
+ type: "text",
199
+ filePath,
200
+ bytesWritten: Buffer.byteLength(content, "utf-8"),
201
+ sandboxed: true,
202
+ },
203
+ isError: false,
204
+ }
205
+ } catch (err: unknown) {
206
+ return {
207
+ name: "sandbox",
208
+ output: { error: `Sandbox write failed: ${(err as Error).message}` },
209
+ isError: true,
210
+ }
211
+ } finally {
212
+ try {
213
+ fs.unlinkSync(tmpFile)
214
+ } catch {
215
+ // cleanup failure is non-critical
216
+ }
217
+ }
218
+ }
219
+
220
+ function truncateOutput(output: string): string {
221
+ if (output.length > 50_000) {
222
+ return output.slice(0, 50_000) + "\n... (truncated)"
223
+ }
224
+ return output
225
+ }