mirror of
https://github.com/samiyev/puaros.git
synced 2025-12-28 07:16:53 +05:00
feat(ipuaro): add git and run tools (v0.9.0)
Git tools: - GitStatusTool: repository status (branch, staged, modified, untracked) - GitDiffTool: uncommitted changes with diff output - GitCommitTool: create commits with confirmation Run tools: - CommandSecurity: blacklist/whitelist shell command validation - RunCommandTool: execute shell commands with security checks - RunTestsTool: auto-detect and run vitest/jest/mocha/npm test All 18 planned tools now implemented. Tests: 1086 (+233), Coverage: 98.08%
This commit is contained in:
155
packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts
Normal file
155
packages/ipuaro/src/infrastructure/tools/git/GitCommitTool.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { type CommitResult, type SimpleGit, simpleGit } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* Author information.
|
||||
*/
|
||||
export interface CommitAuthor {
|
||||
name: string
|
||||
email: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_commit tool.
|
||||
*/
|
||||
export interface GitCommitResult {
|
||||
/** Commit hash */
|
||||
hash: string
|
||||
/** Current branch */
|
||||
branch: string
|
||||
/** Commit message */
|
||||
message: string
|
||||
/** Number of files changed */
|
||||
filesChanged: number
|
||||
/** Number of insertions */
|
||||
insertions: number
|
||||
/** Number of deletions */
|
||||
deletions: number
|
||||
/** Author information */
|
||||
author: CommitAuthor | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for creating git commits.
|
||||
* Requires confirmation before execution.
|
||||
*/
|
||||
export class GitCommitTool implements ITool {
|
||||
readonly name = "git_commit"
|
||||
readonly description =
|
||||
"Create a git commit with the specified message. " +
|
||||
"Will ask for confirmation. Optionally stage specific files first."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "message",
|
||||
type: "string",
|
||||
description: "Commit message",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "files",
|
||||
type: "array",
|
||||
description: "Files to stage before commit (optional, defaults to all staged)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = true
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.message === undefined) {
|
||||
return "Parameter 'message' is required"
|
||||
}
|
||||
if (typeof params.message !== "string") {
|
||||
return "Parameter 'message' must be a string"
|
||||
}
|
||||
if (params.message.trim() === "") {
|
||||
return "Parameter 'message' cannot be empty"
|
||||
}
|
||||
if (params.files !== undefined) {
|
||||
if (!Array.isArray(params.files)) {
|
||||
return "Parameter 'files' must be an array"
|
||||
}
|
||||
for (const file of params.files) {
|
||||
if (typeof file !== "string") {
|
||||
return "Parameter 'files' must be an array of strings"
|
||||
}
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const message = params.message as string
|
||||
const files = params.files as string[] | undefined
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
await git.add(files)
|
||||
}
|
||||
|
||||
const status = await git.status()
|
||||
if (status.staged.length === 0 && (!files || files.length === 0)) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Nothing to commit. Stage files first with 'git add' or provide 'files' parameter.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const commitSummary = `Committing ${String(status.staged.length)} file(s): ${message}`
|
||||
const confirmed = await ctx.requestConfirmation(commitSummary)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(callId, "Commit cancelled by user", Date.now() - startTime)
|
||||
}
|
||||
|
||||
const commitResult = await git.commit(message)
|
||||
const result = this.formatCommitResult(commitResult, message)
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message_ = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message_, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format simple-git CommitResult into our result structure.
|
||||
*/
|
||||
private formatCommitResult(commit: CommitResult, message: string): GitCommitResult {
|
||||
return {
|
||||
hash: commit.commit,
|
||||
branch: commit.branch,
|
||||
message,
|
||||
filesChanged: commit.summary.changes,
|
||||
insertions: commit.summary.insertions,
|
||||
deletions: commit.summary.deletions,
|
||||
author: commit.author ?? null,
|
||||
}
|
||||
}
|
||||
}
|
||||
155
packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts
Normal file
155
packages/ipuaro/src/infrastructure/tools/git/GitDiffTool.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import { simpleGit, type SimpleGit } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* A single file diff entry.
|
||||
*/
|
||||
export interface DiffEntry {
|
||||
/** File path */
|
||||
file: string
|
||||
/** Number of insertions */
|
||||
insertions: number
|
||||
/** Number of deletions */
|
||||
deletions: number
|
||||
/** Whether the file is binary */
|
||||
binary: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_diff tool.
|
||||
*/
|
||||
export interface GitDiffResult {
|
||||
/** Whether showing staged or all changes */
|
||||
staged: boolean
|
||||
/** Path filter applied (null if all files) */
|
||||
pathFilter: string | null
|
||||
/** Whether there are any changes */
|
||||
hasChanges: boolean
|
||||
/** Summary of changes */
|
||||
summary: {
|
||||
/** Number of files changed */
|
||||
filesChanged: number
|
||||
/** Total insertions */
|
||||
insertions: number
|
||||
/** Total deletions */
|
||||
deletions: number
|
||||
}
|
||||
/** List of changed files */
|
||||
files: DiffEntry[]
|
||||
/** Full diff text */
|
||||
diff: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting uncommitted git changes (diff).
|
||||
* Shows what has changed but not yet committed.
|
||||
*/
|
||||
export class GitDiffTool implements ITool {
|
||||
readonly name = "git_diff"
|
||||
readonly description =
|
||||
"Get uncommitted changes (diff). " + "Shows what has changed but not yet committed."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Limit diff to specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "staged",
|
||||
type: "boolean",
|
||||
description: "Show only staged changes (default: false, shows all)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.staged !== undefined && typeof params.staged !== "boolean") {
|
||||
return "Parameter 'staged' must be a boolean"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const pathFilter = (params.path as string) ?? null
|
||||
const staged = (params.staged as boolean) ?? false
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const diffArgs = this.buildDiffArgs(staged, pathFilter)
|
||||
const diffSummary = await git.diffSummary(diffArgs)
|
||||
const diffText = await git.diff(diffArgs)
|
||||
|
||||
const files: DiffEntry[] = diffSummary.files.map((f) => ({
|
||||
file: f.file,
|
||||
insertions: "insertions" in f ? f.insertions : 0,
|
||||
deletions: "deletions" in f ? f.deletions : 0,
|
||||
binary: f.binary,
|
||||
}))
|
||||
|
||||
const result: GitDiffResult = {
|
||||
staged,
|
||||
pathFilter,
|
||||
hasChanges: diffSummary.files.length > 0,
|
||||
summary: {
|
||||
filesChanged: diffSummary.files.length,
|
||||
insertions: diffSummary.insertions,
|
||||
deletions: diffSummary.deletions,
|
||||
},
|
||||
files,
|
||||
diff: diffText,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build diff arguments array.
|
||||
*/
|
||||
private buildDiffArgs(staged: boolean, pathFilter: string | null): string[] {
|
||||
const args: string[] = []
|
||||
|
||||
if (staged) {
|
||||
args.push("--cached")
|
||||
}
|
||||
|
||||
if (pathFilter) {
|
||||
args.push("--", pathFilter)
|
||||
}
|
||||
|
||||
return args
|
||||
}
|
||||
}
|
||||
129
packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts
Normal file
129
packages/ipuaro/src/infrastructure/tools/git/GitStatusTool.ts
Normal file
@@ -0,0 +1,129 @@
|
||||
import { simpleGit, type SimpleGit, type StatusResult } from "simple-git"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
/**
|
||||
* File status entry in git status.
|
||||
*/
|
||||
export interface FileStatusEntry {
|
||||
/** Relative file path */
|
||||
path: string
|
||||
/** Working directory status (modified, deleted, etc.) */
|
||||
workingDir: string
|
||||
/** Index/staging status */
|
||||
index: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Result data from git_status tool.
|
||||
*/
|
||||
export interface GitStatusResult {
|
||||
/** Current branch name */
|
||||
branch: string
|
||||
/** Tracking branch (e.g., origin/main) */
|
||||
tracking: string | null
|
||||
/** Number of commits ahead of tracking */
|
||||
ahead: number
|
||||
/** Number of commits behind tracking */
|
||||
behind: number
|
||||
/** Files staged for commit */
|
||||
staged: FileStatusEntry[]
|
||||
/** Modified files not staged */
|
||||
modified: FileStatusEntry[]
|
||||
/** Untracked files */
|
||||
untracked: string[]
|
||||
/** Files with merge conflicts */
|
||||
conflicted: string[]
|
||||
/** Whether working directory is clean */
|
||||
isClean: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Tool for getting git repository status.
|
||||
* Returns branch info, staged/modified/untracked files.
|
||||
*/
|
||||
export class GitStatusTool implements ITool {
|
||||
readonly name = "git_status"
|
||||
readonly description =
|
||||
"Get current git repository status. " +
|
||||
"Returns branch name, staged files, modified files, and untracked files."
|
||||
readonly parameters: ToolParameterSchema[] = []
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "git" as const
|
||||
|
||||
private readonly gitFactory: (basePath: string) => SimpleGit
|
||||
|
||||
constructor(gitFactory?: (basePath: string) => SimpleGit) {
|
||||
this.gitFactory = gitFactory ?? ((basePath: string) => simpleGit(basePath))
|
||||
}
|
||||
|
||||
validateParams(_params: Record<string, unknown>): string | null {
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(_params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
try {
|
||||
const git = this.gitFactory(ctx.projectRoot)
|
||||
|
||||
const isRepo = await git.checkIsRepo()
|
||||
if (!isRepo) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Not a git repository. Initialize with 'git init' first.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const status = await git.status()
|
||||
const result = this.formatStatus(status)
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Format simple-git StatusResult into our result structure.
|
||||
*/
|
||||
private formatStatus(status: StatusResult): GitStatusResult {
|
||||
const staged: FileStatusEntry[] = []
|
||||
const modified: FileStatusEntry[] = []
|
||||
|
||||
for (const file of status.files) {
|
||||
const entry: FileStatusEntry = {
|
||||
path: file.path,
|
||||
workingDir: file.working_dir,
|
||||
index: file.index,
|
||||
}
|
||||
|
||||
if (file.index !== " " && file.index !== "?") {
|
||||
staged.push(entry)
|
||||
}
|
||||
|
||||
if (file.working_dir !== " " && file.working_dir !== "?") {
|
||||
modified.push(entry)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
branch: status.current ?? "HEAD (detached)",
|
||||
tracking: status.tracking ?? null,
|
||||
ahead: status.ahead,
|
||||
behind: status.behind,
|
||||
staged,
|
||||
modified,
|
||||
untracked: status.not_added,
|
||||
conflicted: status.conflicted,
|
||||
isClean: status.isClean(),
|
||||
}
|
||||
}
|
||||
}
|
||||
6
packages/ipuaro/src/infrastructure/tools/git/index.ts
Normal file
6
packages/ipuaro/src/infrastructure/tools/git/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Git tools exports
|
||||
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./GitStatusTool.js"
|
||||
|
||||
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./GitDiffTool.js"
|
||||
|
||||
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./GitCommitTool.js"
|
||||
@@ -53,3 +53,23 @@ export {
|
||||
type TodoEntry,
|
||||
type TodoType,
|
||||
} from "./analysis/GetTodosTool.js"
|
||||
|
||||
// Git tools
|
||||
export { GitStatusTool, type GitStatusResult, type FileStatusEntry } from "./git/GitStatusTool.js"
|
||||
|
||||
export { GitDiffTool, type GitDiffResult, type DiffEntry } from "./git/GitDiffTool.js"
|
||||
|
||||
export { GitCommitTool, type GitCommitResult, type CommitAuthor } from "./git/GitCommitTool.js"
|
||||
|
||||
// Run tools
|
||||
export {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
type CommandClassification,
|
||||
type SecurityCheckResult,
|
||||
} from "./run/CommandSecurity.js"
|
||||
|
||||
export { RunCommandTool, type RunCommandResult } from "./run/RunCommandTool.js"
|
||||
|
||||
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./run/RunTestsTool.js"
|
||||
|
||||
257
packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts
Normal file
257
packages/ipuaro/src/infrastructure/tools/run/CommandSecurity.ts
Normal file
@@ -0,0 +1,257 @@
|
||||
/**
|
||||
* Command security classification.
|
||||
*/
|
||||
export type CommandClassification = "allowed" | "blocked" | "requires_confirmation"
|
||||
|
||||
/**
|
||||
* Result of command security check.
|
||||
*/
|
||||
export interface SecurityCheckResult {
|
||||
/** Classification of the command */
|
||||
classification: CommandClassification
|
||||
/** Reason for the classification */
|
||||
reason: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Dangerous commands that are always blocked.
|
||||
* These commands can cause data loss or security issues.
|
||||
*/
|
||||
export const DEFAULT_BLACKLIST: string[] = [
|
||||
// Destructive file operations
|
||||
"rm -rf",
|
||||
"rm -r",
|
||||
"rm -fr",
|
||||
"rmdir",
|
||||
// Dangerous git operations
|
||||
"git push --force",
|
||||
"git push -f",
|
||||
"git reset --hard",
|
||||
"git clean -fd",
|
||||
"git clean -f",
|
||||
// Publishing/deployment
|
||||
"npm publish",
|
||||
"yarn publish",
|
||||
"pnpm publish",
|
||||
// System commands
|
||||
"sudo",
|
||||
"su ",
|
||||
"chmod",
|
||||
"chown",
|
||||
// Network/download commands that could be dangerous
|
||||
"| sh",
|
||||
"| bash",
|
||||
// Environment manipulation
|
||||
"export ",
|
||||
"unset ",
|
||||
// Process control
|
||||
"kill -9",
|
||||
"killall",
|
||||
"pkill",
|
||||
// Disk operations (require exact command start)
|
||||
"mkfs",
|
||||
"fdisk",
|
||||
// Other dangerous
|
||||
":(){ :|:& };:",
|
||||
"eval ",
|
||||
]
|
||||
|
||||
/**
|
||||
* Safe commands that don't require confirmation.
|
||||
* Matched by first word (command name).
|
||||
*/
|
||||
export const DEFAULT_WHITELIST: string[] = [
|
||||
// Package managers
|
||||
"npm",
|
||||
"pnpm",
|
||||
"yarn",
|
||||
"npx",
|
||||
"bun",
|
||||
// Node.js
|
||||
"node",
|
||||
"tsx",
|
||||
"ts-node",
|
||||
// Git (read operations)
|
||||
"git",
|
||||
// Build tools
|
||||
"tsc",
|
||||
"tsup",
|
||||
"esbuild",
|
||||
"vite",
|
||||
"webpack",
|
||||
"rollup",
|
||||
// Testing
|
||||
"vitest",
|
||||
"jest",
|
||||
"mocha",
|
||||
"playwright",
|
||||
"cypress",
|
||||
// Linting/formatting
|
||||
"eslint",
|
||||
"prettier",
|
||||
"biome",
|
||||
// Utilities
|
||||
"echo",
|
||||
"cat",
|
||||
"ls",
|
||||
"pwd",
|
||||
"which",
|
||||
"head",
|
||||
"tail",
|
||||
"grep",
|
||||
"find",
|
||||
"wc",
|
||||
"sort",
|
||||
"diff",
|
||||
]
|
||||
|
||||
/**
|
||||
* Git subcommands that are safe and don't need confirmation.
|
||||
*/
|
||||
const SAFE_GIT_SUBCOMMANDS: string[] = [
|
||||
"status",
|
||||
"log",
|
||||
"diff",
|
||||
"show",
|
||||
"branch",
|
||||
"remote",
|
||||
"fetch",
|
||||
"pull",
|
||||
"stash",
|
||||
"tag",
|
||||
"blame",
|
||||
"ls-files",
|
||||
"ls-tree",
|
||||
"rev-parse",
|
||||
"describe",
|
||||
]
|
||||
|
||||
/**
|
||||
* Command security checker.
|
||||
* Determines if a command is safe to execute, blocked, or requires confirmation.
|
||||
*/
|
||||
export class CommandSecurity {
|
||||
private readonly blacklist: string[]
|
||||
private readonly whitelist: string[]
|
||||
|
||||
constructor(blacklist: string[] = DEFAULT_BLACKLIST, whitelist: string[] = DEFAULT_WHITELIST) {
|
||||
this.blacklist = blacklist.map((cmd) => cmd.toLowerCase())
|
||||
this.whitelist = whitelist.map((cmd) => cmd.toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a command is safe to execute.
|
||||
*/
|
||||
check(command: string): SecurityCheckResult {
|
||||
const normalized = command.trim().toLowerCase()
|
||||
|
||||
const blacklistMatch = this.isBlacklisted(normalized)
|
||||
if (blacklistMatch) {
|
||||
return {
|
||||
classification: "blocked",
|
||||
reason: `Command contains blocked pattern: '${blacklistMatch}'`,
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isWhitelisted(normalized)) {
|
||||
return {
|
||||
classification: "allowed",
|
||||
reason: "Command is in the whitelist",
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
classification: "requires_confirmation",
|
||||
reason: "Command is not in the whitelist and requires user confirmation",
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command matches any blacklist pattern.
|
||||
* Returns the matched pattern or null.
|
||||
*/
|
||||
private isBlacklisted(command: string): string | null {
|
||||
for (const pattern of this.blacklist) {
|
||||
if (command.includes(pattern)) {
|
||||
return pattern
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if command's first word is in the whitelist.
|
||||
*/
|
||||
private isWhitelisted(command: string): boolean {
|
||||
const firstWord = this.getFirstWord(command)
|
||||
|
||||
if (!this.whitelist.includes(firstWord)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (firstWord === "git") {
|
||||
return this.isGitCommandSafe(command)
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if git command is safe (read-only operations).
|
||||
*/
|
||||
private isGitCommandSafe(command: string): boolean {
|
||||
const parts = command.split(/\s+/)
|
||||
if (parts.length < 2) {
|
||||
return false
|
||||
}
|
||||
|
||||
const subcommand = parts[1]
|
||||
return SAFE_GIT_SUBCOMMANDS.includes(subcommand)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get first word from command.
|
||||
*/
|
||||
private getFirstWord(command: string): string {
|
||||
const match = /^(\S+)/.exec(command)
|
||||
return match ? match[1] : ""
|
||||
}
|
||||
|
||||
/**
|
||||
* Add patterns to the blacklist.
|
||||
*/
|
||||
addToBlacklist(patterns: string[]): void {
|
||||
for (const pattern of patterns) {
|
||||
const normalized = pattern.toLowerCase()
|
||||
if (!this.blacklist.includes(normalized)) {
|
||||
this.blacklist.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add commands to the whitelist.
|
||||
*/
|
||||
addToWhitelist(commands: string[]): void {
|
||||
for (const cmd of commands) {
|
||||
const normalized = cmd.toLowerCase()
|
||||
if (!this.whitelist.includes(normalized)) {
|
||||
this.whitelist.push(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current blacklist.
|
||||
*/
|
||||
getBlacklist(): string[] {
|
||||
return [...this.blacklist]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current whitelist.
|
||||
*/
|
||||
getWhitelist(): string[] {
|
||||
return [...this.whitelist]
|
||||
}
|
||||
}
|
||||
227
packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts
Normal file
227
packages/ipuaro/src/infrastructure/tools/run/RunCommandTool.ts
Normal file
@@ -0,0 +1,227 @@
|
||||
import { exec } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
import { CommandSecurity } from "./CommandSecurity.js"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Result data from run_command tool.
|
||||
*/
|
||||
export interface RunCommandResult {
|
||||
/** The command that was executed */
|
||||
command: string
|
||||
/** Exit code (0 = success) */
|
||||
exitCode: number
|
||||
/** Standard output */
|
||||
stdout: string
|
||||
/** Standard error output */
|
||||
stderr: string
|
||||
/** Whether command was successful (exit code 0) */
|
||||
success: boolean
|
||||
/** Execution time in milliseconds */
|
||||
durationMs: number
|
||||
/** Whether user confirmation was required */
|
||||
requiredConfirmation: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Default command timeout in milliseconds.
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 30000
|
||||
|
||||
/**
|
||||
* Maximum output size in characters.
|
||||
*/
|
||||
const MAX_OUTPUT_SIZE = 100000
|
||||
|
||||
/**
|
||||
* Tool for executing shell commands.
|
||||
* Commands are checked against blacklist/whitelist for security.
|
||||
*/
|
||||
export class RunCommandTool implements ITool {
|
||||
readonly name = "run_command"
|
||||
readonly description =
|
||||
"Execute a shell command in the project directory. " +
|
||||
"Commands are checked against blacklist/whitelist for security. " +
|
||||
"Unknown commands require user confirmation."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "command",
|
||||
type: "string",
|
||||
description: "Shell command to execute",
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "timeout",
|
||||
type: "number",
|
||||
description: "Timeout in milliseconds (default: 30000)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "run" as const
|
||||
|
||||
private readonly security: CommandSecurity
|
||||
private readonly execFn: typeof execAsync
|
||||
|
||||
constructor(security?: CommandSecurity, execFn?: typeof execAsync) {
|
||||
this.security = security ?? new CommandSecurity()
|
||||
this.execFn = execFn ?? execAsync
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.command === undefined) {
|
||||
return "Parameter 'command' is required"
|
||||
}
|
||||
if (typeof params.command !== "string") {
|
||||
return "Parameter 'command' must be a string"
|
||||
}
|
||||
if (params.command.trim() === "") {
|
||||
return "Parameter 'command' cannot be empty"
|
||||
}
|
||||
if (params.timeout !== undefined) {
|
||||
if (typeof params.timeout !== "number") {
|
||||
return "Parameter 'timeout' must be a number"
|
||||
}
|
||||
if (params.timeout <= 0) {
|
||||
return "Parameter 'timeout' must be positive"
|
||||
}
|
||||
if (params.timeout > 600000) {
|
||||
return "Parameter 'timeout' cannot exceed 600000ms (10 minutes)"
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const command = params.command as string
|
||||
const timeout = (params.timeout as number) ?? DEFAULT_TIMEOUT
|
||||
|
||||
const securityCheck = this.security.check(command)
|
||||
|
||||
if (securityCheck.classification === "blocked") {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Command blocked for security: ${securityCheck.reason}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
let requiredConfirmation = false
|
||||
|
||||
if (securityCheck.classification === "requires_confirmation") {
|
||||
requiredConfirmation = true
|
||||
const confirmed = await ctx.requestConfirmation(
|
||||
`Execute command: ${command}\n\nReason: ${securityCheck.reason}`,
|
||||
)
|
||||
|
||||
if (!confirmed) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"Command execution cancelled by user",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const execStartTime = Date.now()
|
||||
|
||||
const { stdout, stderr } = await this.execFn(command, {
|
||||
cwd: ctx.projectRoot,
|
||||
timeout,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: { ...process.env, FORCE_COLOR: "0" },
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
const result: RunCommandResult = {
|
||||
command,
|
||||
exitCode: 0,
|
||||
stdout: this.truncateOutput(stdout),
|
||||
stderr: this.truncateOutput(stderr),
|
||||
success: true,
|
||||
durationMs,
|
||||
requiredConfirmation,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
return this.handleExecError(callId, command, error, requiredConfirmation, startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle exec errors and return appropriate result.
|
||||
*/
|
||||
private handleExecError(
|
||||
callId: string,
|
||||
command: string,
|
||||
error: unknown,
|
||||
requiredConfirmation: boolean,
|
||||
startTime: number,
|
||||
): ToolResult {
|
||||
if (this.isExecError(error)) {
|
||||
const result: RunCommandResult = {
|
||||
command,
|
||||
exitCode: error.code ?? 1,
|
||||
stdout: this.truncateOutput(error.stdout ?? ""),
|
||||
stderr: this.truncateOutput(error.stderr ?? error.message),
|
||||
success: false,
|
||||
durationMs: Date.now() - startTime,
|
||||
requiredConfirmation,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Command timed out: ${command}`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
return createErrorResult(callId, error.message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
return createErrorResult(callId, String(error), Date.now() - startTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for exec error.
|
||||
*/
|
||||
private isExecError(
|
||||
error: unknown,
|
||||
): error is Error & { code?: number; stdout?: string; stderr?: string } {
|
||||
return error instanceof Error && "code" in error
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output if too large.
|
||||
*/
|
||||
private truncateOutput(output: string): string {
|
||||
if (output.length <= MAX_OUTPUT_SIZE) {
|
||||
return output
|
||||
}
|
||||
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the security checker instance.
|
||||
*/
|
||||
getSecurity(): CommandSecurity {
|
||||
return this.security
|
||||
}
|
||||
}
|
||||
353
packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts
Normal file
353
packages/ipuaro/src/infrastructure/tools/run/RunTestsTool.ts
Normal file
@@ -0,0 +1,353 @@
|
||||
import { exec } from "node:child_process"
|
||||
import { promisify } from "node:util"
|
||||
import * as path from "node:path"
|
||||
import * as fs from "node:fs/promises"
|
||||
import type { ITool, ToolContext, ToolParameterSchema } from "../../../domain/services/ITool.js"
|
||||
import {
|
||||
createErrorResult,
|
||||
createSuccessResult,
|
||||
type ToolResult,
|
||||
} from "../../../domain/value-objects/ToolResult.js"
|
||||
|
||||
const execAsync = promisify(exec)
|
||||
|
||||
/**
|
||||
* Supported test runners.
|
||||
*/
|
||||
export type TestRunner = "vitest" | "jest" | "mocha" | "npm"
|
||||
|
||||
/**
|
||||
* Result data from run_tests tool.
|
||||
*/
|
||||
export interface RunTestsResult {
|
||||
/** Test runner that was used */
|
||||
runner: TestRunner
|
||||
/** Command that was executed */
|
||||
command: string
|
||||
/** Whether all tests passed */
|
||||
passed: boolean
|
||||
/** Exit code */
|
||||
exitCode: number
|
||||
/** Standard output */
|
||||
stdout: string
|
||||
/** Standard error output */
|
||||
stderr: string
|
||||
/** Execution time in milliseconds */
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Default test timeout in milliseconds (5 minutes).
|
||||
*/
|
||||
const DEFAULT_TIMEOUT = 300000
|
||||
|
||||
/**
|
||||
* Maximum output size in characters.
|
||||
*/
|
||||
const MAX_OUTPUT_SIZE = 200000
|
||||
|
||||
/**
|
||||
* Tool for running project tests.
|
||||
* Auto-detects test runner (vitest, jest, mocha, npm test).
|
||||
*/
|
||||
export class RunTestsTool implements ITool {
|
||||
readonly name = "run_tests"
|
||||
readonly description =
|
||||
"Run the project's test suite. Auto-detects test runner (vitest, jest, npm test). " +
|
||||
"Returns test results summary."
|
||||
readonly parameters: ToolParameterSchema[] = [
|
||||
{
|
||||
name: "path",
|
||||
type: "string",
|
||||
description: "Run tests for specific file or directory",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "filter",
|
||||
type: "string",
|
||||
description: "Filter tests by name pattern",
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: "watch",
|
||||
type: "boolean",
|
||||
description: "Run in watch mode (default: false)",
|
||||
required: false,
|
||||
},
|
||||
]
|
||||
readonly requiresConfirmation = false
|
||||
readonly category = "run" as const
|
||||
|
||||
private readonly execFn: typeof execAsync
|
||||
private readonly fsAccess: typeof fs.access
|
||||
private readonly fsReadFile: typeof fs.readFile
|
||||
|
||||
constructor(
|
||||
execFn?: typeof execAsync,
|
||||
fsAccess?: typeof fs.access,
|
||||
fsReadFile?: typeof fs.readFile,
|
||||
) {
|
||||
this.execFn = execFn ?? execAsync
|
||||
this.fsAccess = fsAccess ?? fs.access
|
||||
this.fsReadFile = fsReadFile ?? fs.readFile
|
||||
}
|
||||
|
||||
validateParams(params: Record<string, unknown>): string | null {
|
||||
if (params.path !== undefined && typeof params.path !== "string") {
|
||||
return "Parameter 'path' must be a string"
|
||||
}
|
||||
if (params.filter !== undefined && typeof params.filter !== "string") {
|
||||
return "Parameter 'filter' must be a string"
|
||||
}
|
||||
if (params.watch !== undefined && typeof params.watch !== "boolean") {
|
||||
return "Parameter 'watch' must be a boolean"
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
async execute(params: Record<string, unknown>, ctx: ToolContext): Promise<ToolResult> {
|
||||
const startTime = Date.now()
|
||||
const callId = `${this.name}-${String(startTime)}`
|
||||
|
||||
const testPath = params.path as string | undefined
|
||||
const filter = params.filter as string | undefined
|
||||
const watch = (params.watch as boolean) ?? false
|
||||
|
||||
try {
|
||||
const runner = await this.detectTestRunner(ctx.projectRoot)
|
||||
|
||||
if (!runner) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
"No test runner detected. Ensure vitest, jest, or mocha is installed, or 'test' script exists in package.json.",
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
|
||||
const command = this.buildCommand(runner, testPath, filter, watch)
|
||||
const execStartTime = Date.now()
|
||||
|
||||
try {
|
||||
const { stdout, stderr } = await this.execFn(command, {
|
||||
cwd: ctx.projectRoot,
|
||||
timeout: DEFAULT_TIMEOUT,
|
||||
maxBuffer: MAX_OUTPUT_SIZE,
|
||||
env: { ...process.env, FORCE_COLOR: "0", CI: "true" },
|
||||
})
|
||||
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
const result: RunTestsResult = {
|
||||
runner,
|
||||
command,
|
||||
passed: true,
|
||||
exitCode: 0,
|
||||
stdout: this.truncateOutput(stdout),
|
||||
stderr: this.truncateOutput(stderr),
|
||||
durationMs,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
} catch (error) {
|
||||
return this.handleExecError(
|
||||
callId,
|
||||
runner,
|
||||
command,
|
||||
error,
|
||||
execStartTime,
|
||||
startTime,
|
||||
)
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return createErrorResult(callId, message, Date.now() - startTime)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which test runner is available in the project.
|
||||
*/
|
||||
async detectTestRunner(projectRoot: string): Promise<TestRunner | null> {
|
||||
if (await this.hasFile(projectRoot, "vitest.config.ts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.js")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "vitest.config.mts")) {
|
||||
return "vitest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.js")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.ts")) {
|
||||
return "jest"
|
||||
}
|
||||
if (await this.hasFile(projectRoot, "jest.config.json")) {
|
||||
return "jest"
|
||||
}
|
||||
|
||||
const packageJsonPath = path.join(projectRoot, "package.json")
|
||||
try {
|
||||
const content = await this.fsReadFile(packageJsonPath, "utf-8")
|
||||
const pkg = JSON.parse(content) as {
|
||||
scripts?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
dependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
if (pkg.devDependencies?.vitest || pkg.dependencies?.vitest) {
|
||||
return "vitest"
|
||||
}
|
||||
if (pkg.devDependencies?.jest || pkg.dependencies?.jest) {
|
||||
return "jest"
|
||||
}
|
||||
if (pkg.devDependencies?.mocha || pkg.dependencies?.mocha) {
|
||||
return "mocha"
|
||||
}
|
||||
|
||||
if (pkg.scripts?.test) {
|
||||
return "npm"
|
||||
}
|
||||
} catch {
|
||||
// package.json doesn't exist or is invalid
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the test command based on runner and options.
|
||||
*/
|
||||
buildCommand(runner: TestRunner, testPath?: string, filter?: string, watch?: boolean): string {
|
||||
const parts: string[] = []
|
||||
|
||||
switch (runner) {
|
||||
case "vitest":
|
||||
parts.push("npx vitest")
|
||||
if (!watch) {
|
||||
parts.push("run")
|
||||
}
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
break
|
||||
|
||||
case "jest":
|
||||
parts.push("npx jest")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("-t", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "mocha":
|
||||
parts.push("npx mocha")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push("--grep", `"${filter}"`)
|
||||
}
|
||||
if (watch) {
|
||||
parts.push("--watch")
|
||||
}
|
||||
break
|
||||
|
||||
case "npm":
|
||||
parts.push("npm test")
|
||||
if (testPath || filter) {
|
||||
parts.push("--")
|
||||
if (testPath) {
|
||||
parts.push(testPath)
|
||||
}
|
||||
if (filter) {
|
||||
parts.push(`"${filter}"`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file exists.
|
||||
*/
|
||||
private async hasFile(projectRoot: string, filename: string): Promise<boolean> {
|
||||
try {
|
||||
await this.fsAccess(path.join(projectRoot, filename))
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle exec errors and return appropriate result.
|
||||
*/
|
||||
private handleExecError(
|
||||
callId: string,
|
||||
runner: TestRunner,
|
||||
command: string,
|
||||
error: unknown,
|
||||
execStartTime: number,
|
||||
startTime: number,
|
||||
): ToolResult {
|
||||
const durationMs = Date.now() - execStartTime
|
||||
|
||||
if (this.isExecError(error)) {
|
||||
const result: RunTestsResult = {
|
||||
runner,
|
||||
command,
|
||||
passed: false,
|
||||
exitCode: error.code ?? 1,
|
||||
stdout: this.truncateOutput(error.stdout ?? ""),
|
||||
stderr: this.truncateOutput(error.stderr ?? error.message),
|
||||
durationMs,
|
||||
}
|
||||
|
||||
return createSuccessResult(callId, result, Date.now() - startTime)
|
||||
}
|
||||
|
||||
if (error instanceof Error) {
|
||||
if (error.message.includes("ETIMEDOUT") || error.message.includes("timed out")) {
|
||||
return createErrorResult(
|
||||
callId,
|
||||
`Tests timed out after ${String(DEFAULT_TIMEOUT / 1000)} seconds`,
|
||||
Date.now() - startTime,
|
||||
)
|
||||
}
|
||||
return createErrorResult(callId, error.message, Date.now() - startTime)
|
||||
}
|
||||
|
||||
return createErrorResult(callId, String(error), Date.now() - startTime)
|
||||
}
|
||||
|
||||
/**
|
||||
* Type guard for exec error.
|
||||
*/
|
||||
private isExecError(
|
||||
error: unknown,
|
||||
): error is Error & { code?: number; stdout?: string; stderr?: string } {
|
||||
return error instanceof Error && "code" in error
|
||||
}
|
||||
|
||||
/**
|
||||
* Truncate output if too large.
|
||||
*/
|
||||
private truncateOutput(output: string): string {
|
||||
if (output.length <= MAX_OUTPUT_SIZE) {
|
||||
return output
|
||||
}
|
||||
return `${output.slice(0, MAX_OUTPUT_SIZE)}\n... (output truncated)`
|
||||
}
|
||||
}
|
||||
12
packages/ipuaro/src/infrastructure/tools/run/index.ts
Normal file
12
packages/ipuaro/src/infrastructure/tools/run/index.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Run tools exports
|
||||
export {
|
||||
CommandSecurity,
|
||||
DEFAULT_BLACKLIST,
|
||||
DEFAULT_WHITELIST,
|
||||
type CommandClassification,
|
||||
type SecurityCheckResult,
|
||||
} from "./CommandSecurity.js"
|
||||
|
||||
export { RunCommandTool, type RunCommandResult } from "./RunCommandTool.js"
|
||||
|
||||
export { RunTestsTool, type RunTestsResult, type TestRunner } from "./RunTestsTool.js"
|
||||
Reference in New Issue
Block a user