diff --git a/package-lock.json b/package-lock.json index 7945dea..3a17733 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "gitclaw", - "version": "1.1.3", + "version": "1.1.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "gitclaw", - "version": "1.1.3", + "version": "1.1.6", "license": "MIT", "dependencies": { "@googleworkspace/cli": "^0.8.1", @@ -732,13 +732,13 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.9.tgz", - "integrity": "sha512-ItnlMgSqkPrUfJs7EsvU/01zw5UeIb2tNPhD09LBLHbg+g+HDiKibSLwpkuz/ZIlz4F2IMn+5XgE4AK/pfPuog==", + "version": "3.972.13", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.13.tgz", + "integrity": "sha512-I/+BMxM4WE/6xL0tyV7tAUDOAXmyw/va1oGr/eSly43HmLUcD1G+v96vEKAA8VoLcZ03ZQo/PWzjmN9zQErqPQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.13.0", - "fast-xml-parser": "5.4.1", + "@smithy/types": "^4.13.1", + "fast-xml-parser": "5.5.6", "tslib": "^2.6.2" }, "engines": { @@ -2487,9 +2487,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.13.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.0.tgz", - "integrity": "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw==", + "version": "4.13.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.13.1.tgz", + "integrity": "sha512-787F3yzE2UiJIQ+wYW1CVg2odHjmaWLGksnKQHUrK/lYZSEcy1msuLVvxaR/sI2/aDe9U+TBuLsXnr3vod1g0g==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -3236,21 +3236,24 @@ "license": "BSD-3-Clause" }, "node_modules/fast-xml-builder": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.0.0.tgz", - "integrity": "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", + "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", "funding": [ { "type": "github", "url": "https://github.com/sponsors/NaturalIntelligence" } ], - "license": "MIT" + "license": "MIT", + "dependencies": { + "path-expression-matcher": "^1.1.3" + } }, "node_modules/fast-xml-parser": { - "version": "5.4.1", - "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.4.1.tgz", - "integrity": "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A==", + "version": "5.5.6", + "resolved": "https://registry.npmjs.org/fast-xml-parser/-/fast-xml-parser-5.5.6.tgz", + "integrity": "sha512-3+fdZyBRVg29n4rXP0joHthhcHdPUHaIC16cuyyd1iLsuaO6Vea36MPrxgAzbZna8lhvZeRL8Bc9GP56/J9xEw==", "funding": [ { "type": "github", @@ -3259,7 +3262,8 @@ ], "license": "MIT", "dependencies": { - "fast-xml-builder": "^1.0.0", + "fast-xml-builder": "^1.1.4", + "path-expression-matcher": "^1.1.3", "strnum": "^2.1.2" }, "bin": { @@ -3914,6 +3918,21 @@ "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", "license": "MIT" }, + "node_modules/path-expression-matcher": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/path-expression-matcher/-/path-expression-matcher-1.1.3.tgz", + "integrity": "sha512-qdVgY8KXmVdJZRSS1JdEPOKPdTiEK/pi0RkcT2sw1RhXxohdujUlJFPuS1TSkevZ9vzd3ZlL7ULl1MHGTApKzQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/NaturalIntelligence" + } + ], + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, "node_modules/path-key": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", @@ -4390,9 +4409,9 @@ } }, "node_modules/strnum": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.0.tgz", - "integrity": "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/strnum/-/strnum-2.2.1.tgz", + "integrity": "sha512-BwRvNd5/QoAtyW1na1y1LsJGQNvRlkde6Q/ipqqEaivoMdV+B1OMOTVdwR+N/cwVUcIt9PYyHmV8HyexCZSupg==", "funding": [ { "type": "github", @@ -4483,9 +4502,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.24.4", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.24.4.tgz", + "integrity": "sha512-BM/JzwwaRXxrLdElV2Uo6cTLEjhSb3WXboncJamZ15NgUURmvlXvxa6xkwIOILIjPNo9i8ku136ZvWV0Uly8+w==", "license": "MIT", "engines": { "node": ">=20.18.1" diff --git a/package.json b/package.json index 729c104..31139fc 100644 --- a/package.json +++ b/package.json @@ -37,7 +37,7 @@ } }, "scripts": { - "build": "tsc && cp src/voice/ui.html dist/voice/", + "build": "tsc && copy src\\voice\\ui.html dist\\voice\\", "dev": "tsc --watch", "start": "node dist/index.js", "test": "node --test test/*.test.ts --experimental-strip-types" diff --git a/src/hooks.ts b/src/hooks.ts index b473b58..9161803 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,6 +1,6 @@ import { spawn } from "child_process"; import { readFile } from "fs/promises"; -import { join, resolve } from "path"; +import { join, resolve, sep } from "path"; import yaml from "js-yaml"; import type { AgentTool } from "@mariozechner/pi-agent-core"; @@ -63,12 +63,15 @@ async function executeHook( // Path traversal guard: ensure script doesn't escape its base directory const resolvedScript = resolve(scriptPath); const allowedBase = resolve(baseDir); - if (!resolvedScript.startsWith(allowedBase + "/") && resolvedScript !== allowedBase) { + if (!resolvedScript.startsWith(allowedBase + sep) && resolvedScript !== allowedBase) { reject(new Error(`Hook "${hook.script}" escapes its base directory`)); return; } - const child = spawn("sh", [resolvedScript], { + const isWin = process.platform === "win32"; + const shell = isWin ? "cmd" : "sh"; + const shellArgs = isWin ? ["/c", resolvedScript] : [resolvedScript]; + const child = spawn(shell, shellArgs, { cwd: baseDir, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, diff --git a/src/index.ts b/src/index.ts index 6a9a0c8..49a751e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ import { formatComplianceWarnings } from "./compliance.js"; import { readFile, mkdir, writeFile, stat, access } from "fs/promises"; import { existsSync, readFileSync } from "fs"; import { join, resolve } from "path"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { initLocalSession } from "./session.js"; import type { LocalSession } from "./session.js"; import { startVoiceServer } from "./voice/server.js"; @@ -190,7 +190,7 @@ function askQuestion(question: string): Promise { function isGitRepo(dir: string): boolean { try { - execSync("git rev-parse --is-inside-work-tree", { cwd: dir, stdio: "pipe" }); + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: dir, stdio: "pipe" }); return true; } catch { return false; @@ -218,7 +218,7 @@ async function ensureRepo(dir: string, model?: string): Promise { // Git init if not a repo if (!isGitRepo(absDir)) { console.log(dim("Initializing git repository...")); - execSync("git init", { cwd: absDir, stdio: "pipe" }); + execFileSync("git", ["init"], { cwd: absDir, stdio: "pipe" }); // Create .gitignore const gitignorePath = join(absDir, ".gitignore"); @@ -227,7 +227,8 @@ async function ensureRepo(dir: string, model?: string): Promise { } // Initial commit so memory saves work - execSync("git add -A && git commit -m 'Initial commit' --allow-empty", { + execFileSync("git", ["add", "-A"], { cwd: absDir, stdio: "pipe" }); + execFileSync("git", ["commit", "-m", "Initial commit", "--allow-empty"], { cwd: absDir, stdio: "pipe", }); @@ -284,10 +285,13 @@ async function ensureRepo(dir: string, model?: string): Promise { // Stage new scaffolded files try { - execSync("git add -A && git diff --cached --quiet || git commit -m 'Scaffold gitclaw agent'", { - cwd: absDir, - stdio: "pipe", - }); + execFileSync("git", ["add", "-A"], { cwd: absDir, stdio: "pipe" }); + try { + execFileSync("git", ["diff", "--cached", "--quiet"], { cwd: absDir, stdio: "pipe" }); + } catch { + // There are staged changes — commit them + execFileSync("git", ["commit", "-m", "Scaffold gitclaw agent"], { cwd: absDir, stdio: "pipe" }); + } } catch { // ok if nothing to commit } diff --git a/src/loader.ts b/src/loader.ts index 95e36ee..e2b87f8 100644 --- a/src/loader.ts +++ b/src/loader.ts @@ -1,7 +1,7 @@ import { readFile, mkdir, writeFile } from "fs/promises"; import { join } from "path"; import { randomUUID } from "crypto"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { getModel } from "@mariozechner/pi-ai"; import type { Model } from "@mariozechner/pi-ai"; import yaml from "js-yaml"; @@ -158,7 +158,7 @@ async function resolveInheritance( const parentDir = join(depsDir, parentName); try { - execSync(`git clone --depth 1 "${manifest.extends}" "${parentDir}" 2>/dev/null || true`, { + execFileSync("git", ["clone", "--depth", "1", manifest.extends, parentDir], { cwd: agentDir, stdio: "pipe", }); @@ -204,8 +204,8 @@ async function resolveDependencies( for (const dep of manifest.dependencies) { const depDir = join(depsDir, dep.name); try { - execSync( - `git clone --depth 1 --branch "${dep.version}" "${dep.source}" "${depDir}" 2>/dev/null || true`, + execFileSync( + "git", ["clone", "--depth", "1", "--branch", dep.version, dep.source, depDir], { cwd: agentDir, stdio: "pipe" }, ); } catch { diff --git a/src/plugin-cli.ts b/src/plugin-cli.ts index 2e3214f..461577e 100644 --- a/src/plugin-cli.ts +++ b/src/plugin-cli.ts @@ -1,6 +1,5 @@ import { readFile, writeFile, mkdir, rm, cp, stat } from "fs/promises"; import { join, resolve } from "path"; -import { execSync } from "child_process"; import yaml from "js-yaml"; // "yaml" (v2) is used here instead of js-yaml because parseDocument() // preserves comments and formatting when editing agent.yaml. diff --git a/src/sandbox.ts b/src/sandbox.ts index 556816d..3309c44 100644 --- a/src/sandbox.ts +++ b/src/sandbox.ts @@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; // ── Types ─────────────────────────────────────────────────────────────── @@ -31,8 +31,7 @@ export interface SandboxContext { function detectRepoUrl(dir: string): string | null { try { - return execSync("git remote get-url origin", { cwd: dir, stdio: "pipe" }) - .toString() + return execFileSync("git", ["remote", "get-url", "origin"], { cwd: dir, stdio: "pipe", encoding: "utf-8" }) .trim(); } catch { return null; diff --git a/src/session.ts b/src/session.ts index 86194fa..653f6c9 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1,4 +1,4 @@ -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import { existsSync, mkdirSync, writeFileSync } from "fs"; import { resolve } from "path"; import { randomBytes } from "crypto"; @@ -32,19 +32,23 @@ function cleanUrl(url: string): string { return url.replace(/^https:\/\/[^@]+@/, "https://"); } -function git(args: string, cwd: string): string { - return execSync(`git ${args}`, { cwd, stdio: "pipe", encoding: "utf-8" }).trim(); +/** + * Safe git execution using argument arrays to prevent command injection. + * Inputs are passed as separate arguments and never interpreted by a shell. + */ +function git(args: string[], cwd: string): string { + return execFileSync("git", args, { cwd, stdio: "pipe", encoding: "utf-8" }).trim(); } function getDefaultBranch(cwd: string): string { try { // e.g. "origin/main" → "main" - const ref = git("symbolic-ref refs/remotes/origin/HEAD", cwd); + const ref = git(["symbolic-ref", "refs/remotes/origin/HEAD"], cwd); return ref.replace("refs/remotes/origin/", ""); } catch { // Fallback: try main, then master try { - git("rev-parse --verify origin/main", cwd); + git(["rev-parse", "--verify", "origin/main"], cwd); return "main"; } catch { return "master"; @@ -61,15 +65,15 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession { // Clone or update if (!existsSync(dir)) { - execSync(`git clone --depth 1 --no-single-branch ${aUrl} ${dir}`, { stdio: "pipe" }); + execFileSync("git", ["clone", "--depth", "1", "--no-single-branch", aUrl, dir], { stdio: "pipe" }); } else { - git(`remote set-url origin ${aUrl}`, dir); - git("fetch origin", dir); + git(["remote", "set-url", "origin", aUrl], dir); + git(["fetch", "origin"], dir); // Reset local default branch to latest remote const defaultBranch = getDefaultBranch(dir); - git(`checkout ${defaultBranch}`, dir); - git(`reset --hard origin/${defaultBranch}`, dir); + git(["checkout", defaultBranch], dir); + git(["reset", "--hard", `origin/${defaultBranch}`], dir); } // Determine branch @@ -83,17 +87,17 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession { // Try local checkout first, fall back to remote tracking try { - git(`checkout ${branch}`, dir); + git(["checkout", branch], dir); } catch { - git(`checkout -b ${branch} origin/${branch}`, dir); + git(["checkout", "-b", branch, `origin/${branch}`], dir); } // Pull latest for existing session branch - try { git(`pull origin ${branch}`, dir); } catch { /* branch may not exist on remote yet */ } + try { git(["pull", "origin", branch], dir); } catch { /* branch may not exist on remote yet */ } } else { // New session — branch off latest default branch sessionId = randomBytes(4).toString("hex"); // 8-char hex branch = `gitclaw/session-${sessionId}`; - git(`checkout -b ${branch}`, dir); + git(["checkout", "-b", branch], dir); } // Scaffold agent.yaml + memory if missing (on session branch only) @@ -128,26 +132,26 @@ export function initLocalSession(opts: LocalRepoOptions): LocalSession { sessionId, commitChanges(msg?: string) { - git("add -A", dir); + git(["add", "-A"], dir); try { - git("diff --cached --quiet", dir); + git(["diff", "--cached", "--quiet"], dir); // Nothing staged — skip } catch { // There are staged changes const commitMsg = msg || `gitclaw: auto-commit (${branch})`; - git(`commit -m "${commitMsg}"`, dir); + git(["commit", "-m", commitMsg], dir); } }, push() { - git(`push origin ${branch}`, dir); + git(["push", "origin", branch], dir); }, finalize() { localSession.commitChanges(); localSession.push(); // Strip PAT from remote URL - git(`remote set-url origin ${cleanUrl(url)}`, dir); + git(["remote", "set-url", "origin", cleanUrl(url)], dir); }, }; diff --git a/src/tool-loader.ts b/src/tool-loader.ts index 5c58f02..602b044 100644 --- a/src/tool-loader.ts +++ b/src/tool-loader.ts @@ -68,7 +68,10 @@ function createDeclarativeTool( if (signal?.aborted) throw new Error("Operation aborted"); return new Promise((resolve, reject) => { - const child = spawn(runtime, [scriptPath], { + const isWin = process.platform === "win32"; + const shellCmd = isWin ? "cmd" : runtime; + const shellArgs = isWin ? ["/c", scriptPath] : [scriptPath]; + const child = spawn(shellCmd, shellArgs, { cwd: agentDir, stdio: ["pipe", "pipe", "pipe"], env: { ...process.env }, diff --git a/src/tools/capture-photo.ts b/src/tools/capture-photo.ts index 2733fb7..211f161 100644 --- a/src/tools/capture-photo.ts +++ b/src/tools/capture-photo.ts @@ -1,6 +1,6 @@ import { readFile, writeFile, mkdir, stat } from "fs/promises"; import { join } from "path"; -import { execSync } from "child_process"; +import { execFileSync } from "child_process"; import type { AgentTool } from "@mariozechner/pi-agent-core"; import { capturePhotoSchema } from "./shared.js"; @@ -85,7 +85,8 @@ export function createCapturePhotoTool(cwd: string): AgentTool