diff --git a/src/security/skill-scanner.test.ts b/src/security/skill-scanner.test.ts index b960e8997e4..a215b1d5fff 100644 --- a/src/security/skill-scanner.test.ts +++ b/src/security/skill-scanner.test.ts @@ -241,6 +241,17 @@ const options: ExecOptions = { timeout: 5000 }; expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false); }); + it("does not flag argv-only self-reexec as shell command execution", () => { + const source = ` +import { spawn } from "node:child_process"; +const child = spawn(process.execPath, [distEntryPath, ...args], { + stdio: ["pipe", "pipe", "pipe"], +}); +`; + const findings = scanSource(source, "plugin.ts"); + expect(findings.some((f) => f.ruleId === "dangerous-exec")).toBe(false); + }); + it("returns empty array for clean plugin code", () => { const source = ` export function greet(name: string): string { diff --git a/src/security/skill-scanner.ts b/src/security/skill-scanner.ts index 2e2ffd14d2b..0172817fab4 100644 --- a/src/security/skill-scanner.ts +++ b/src/security/skill-scanner.ts @@ -216,6 +216,15 @@ function truncateEvidence(evidence: string, maxLen = 120): string { return `${evidence.slice(0, maxLen)}…`; } +function isAllowedDangerousExecEvidence(rule: LineRule, line: string): boolean { + if (rule.ruleId !== "dangerous-exec") { + return false; + } + // Spawning the current Node executable with an argv array is not shell + // execution. Keep direct shell/process launches blocked below. + return /\bspawn\s*\(\s*process\.execPath\s*,/.test(line); +} + export function scanSource(source: string, filePath: string): SkillScanFinding[] { const findings: SkillScanFinding[] = []; const lines = source.split("\n"); @@ -238,6 +247,9 @@ export function scanSource(source: string, filePath: string): SkillScanFinding[] if (!match) { continue; } + if (isAllowedDangerousExecEvidence(rule, line)) { + continue; + } // Special handling for suspicious-network: check port if (rule.ruleId === "suspicious-network") {