@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.
- package/README.md +181 -0
- package/dist/agent/conversation.d.ts.map +1 -0
- package/dist/agent/loop.d.ts.map +1 -0
- package/dist/agent/planner.d.ts.map +1 -0
- package/dist/auth/client.d.ts.map +1 -0
- package/dist/auth/config.d.ts.map +1 -0
- package/dist/commands/chat.d.ts.map +1 -0
- package/dist/commands/config.d.ts.map +1 -0
- package/dist/commands/login.d.ts.map +1 -0
- package/dist/commands/marketplace.d.ts.map +1 -0
- package/dist/commands/models.d.ts.map +1 -0
- package/dist/compact/history-store.d.ts.map +1 -0
- package/dist/compact/microcompact.d.ts.map +1 -0
- package/dist/compact/service.d.ts.map +1 -0
- package/dist/context/analyzer.d.ts.map +1 -0
- package/dist/context/builder.d.ts.map +1 -0
- package/dist/context/cache.d.ts.map +1 -0
- package/dist/context/git-context.d.ts.map +1 -0
- package/dist/context/llmtune-md.d.ts.map +1 -0
- package/dist/context/workspace.d.ts.map +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +125 -0
- package/dist/marketplace/client.d.ts.map +1 -0
- package/dist/memory/files.d.ts.map +1 -0
- package/dist/memory/service.d.ts.map +1 -0
- package/dist/repl/repl.d.ts.map +1 -0
- package/dist/skills/args.d.ts.map +1 -0
- package/dist/skills/frontmatter.d.ts.map +1 -0
- package/dist/skills/loader.d.ts.map +1 -0
- package/dist/skills/registry.d.ts.map +1 -0
- package/dist/skills/signing/signer.d.ts.map +1 -0
- package/dist/skills/trust.d.ts.map +1 -0
- package/dist/telemetry/logger.d.ts.map +1 -0
- package/dist/tools/permissions.d.ts.map +1 -0
- package/dist/tools/protocol.d.ts.map +1 -0
- package/dist/tools/registry.d.ts.map +1 -0
- package/dist/tools/sandbox/docker.d.ts.map +1 -0
- package/dist/tools/sandbox/index.d.ts.map +1 -0
- package/dist/tools/tools/ask-user.d.ts.map +1 -0
- package/dist/tools/tools/bash.d.ts.map +1 -0
- package/dist/tools/tools/edit.d.ts.map +1 -0
- package/dist/tools/tools/glob.d.ts.map +1 -0
- package/dist/tools/tools/grep.d.ts.map +1 -0
- package/dist/tools/tools/read.d.ts.map +1 -0
- package/dist/tools/tools/web-fetch.d.ts.map +1 -0
- package/dist/tools/tools/write.d.ts.map +1 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/utils/markdown.d.ts.map +1 -0
- package/dist/utils/streaming.d.ts.map +1 -0
- package/dist/utils/tokens.d.ts.map +1 -0
- package/docs/SKILL_AUTHORING.md +175 -0
- package/package.json +38 -0
- package/src/agent/conversation.ts +140 -0
- package/src/agent/loop.ts +215 -0
- package/src/agent/planner.ts +55 -0
- package/src/auth/client.ts +19 -0
- package/src/auth/config.ts +89 -0
- package/src/commands/chat.ts +28 -0
- package/src/commands/config.ts +36 -0
- package/src/commands/login.ts +63 -0
- package/src/commands/marketplace.ts +190 -0
- package/src/commands/models.ts +74 -0
- package/src/compact/history-store.ts +101 -0
- package/src/compact/microcompact.ts +49 -0
- package/src/compact/service.ts +154 -0
- package/src/context/analyzer.ts +127 -0
- package/src/context/builder.ts +123 -0
- package/src/context/cache.ts +11 -0
- package/src/context/git-context.ts +58 -0
- package/src/context/llmtune-md.ts +48 -0
- package/src/context/workspace.ts +139 -0
- package/src/index.ts +100 -0
- package/src/marketplace/client.ts +118 -0
- package/src/memory/files.ts +81 -0
- package/src/memory/service.ts +124 -0
- package/src/repl/repl.ts +400 -0
- package/src/skills/args.ts +35 -0
- package/src/skills/builtin/explain-code/SKILL.md +30 -0
- package/src/skills/frontmatter.ts +47 -0
- package/src/skills/loader.ts +25 -0
- package/src/skills/registry.ts +155 -0
- package/src/skills/signing/signer.ts +101 -0
- package/src/skills/trust.ts +50 -0
- package/src/telemetry/logger.ts +108 -0
- package/src/tools/permissions.ts +83 -0
- package/src/tools/protocol.ts +24 -0
- package/src/tools/registry.ts +93 -0
- package/src/tools/sandbox/docker.ts +225 -0
- package/src/tools/sandbox/index.ts +91 -0
- package/src/tools/tools/ask-user.ts +60 -0
- package/src/tools/tools/bash.ts +97 -0
- package/src/tools/tools/edit.ts +111 -0
- package/src/tools/tools/glob.ts +68 -0
- package/src/tools/tools/grep.ts +121 -0
- package/src/tools/tools/read.ts +57 -0
- package/src/tools/tools/web-fetch.ts +158 -0
- package/src/tools/tools/write.ts +52 -0
- package/src/tools/validation.ts +164 -0
- package/src/utils/markdown.ts +96 -0
- package/src/utils/streaming.ts +63 -0
- package/src/utils/tokens.ts +41 -0
- 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
|
+
}
|