diff --git a/packages/cli/package.json b/packages/cli/package.json index 8545797e1..27f92ca85 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "@openrouter/spawn", - "version": "0.30.1", + "version": "0.30.2", "type": "module", "bin": { "spawn": "cli.js" diff --git a/packages/cli/src/shared/agent-setup.ts b/packages/cli/src/shared/agent-setup.ts index 823721a76..ff13d3c82 100644 --- a/packages/cli/src/shared/agent-setup.ts +++ b/packages/cli/src/shared/agent-setup.ts @@ -42,6 +42,27 @@ export interface CloudRunner { downloadFile(remotePath: string, localPath: string): Promise; } +// ─── Script template validation ──────────────────────────────────────────── + +/** + * Validate that a script template string does not contain JS template + * interpolation patterns (`${...}`) before it is base64-encoded for shell + * injection into systemd units or remote commands. + * + * Defense-in-depth: the scripts are currently static string arrays joined + * with `\n`, so they should never contain interpolation markers. This guard + * catches future regressions where a developer might accidentally introduce + * template literal interpolation before encoding. + * + * Note: backticks alone are allowed (used in markdown content for skill + * files), but `${` is always rejected as it indicates JS interpolation. + */ +export function validateScriptTemplate(script: string, label: string): void { + if (/\$\{/.test(script)) { + throw new Error(`Script template "${label}" contains \${} interpolation — refusing to encode`); + } +} + // ─── Install helpers ──────────────────────────────────────────────────────── async function installAgent( @@ -550,6 +571,9 @@ export async function startGateway(runner: CloudRunner): Promise { "WantedBy=multi-user.target", ].join("\n"); + validateScriptTemplate(wrapperScript, "gateway-wrapper"); + validateScriptTemplate(unitFile, "gateway-unit"); + const wrapperB64 = Buffer.from(wrapperScript).toString("base64"); const unitB64 = Buffer.from(unitFile).toString("base64"); if (!/^[A-Za-z0-9+/=]+$/.test(wrapperB64)) { @@ -811,6 +835,10 @@ export async function setupAutoUpdate(runner: CloudRunner, agentName: string, up "WantedBy=timers.target", ].join("\n"); + validateScriptTemplate(wrapperScript, "auto-update-wrapper"); + validateScriptTemplate(unitFile, "auto-update-unit"); + validateScriptTemplate(timerFile, "auto-update-timer"); + const wrapperB64 = Buffer.from(wrapperScript).toString("base64"); const unitB64 = Buffer.from(unitFile).toString("base64"); const timerB64 = Buffer.from(timerFile).toString("base64"); diff --git a/packages/cli/src/shared/spawn-skill.ts b/packages/cli/src/shared/spawn-skill.ts index 94a37449a..f72e0ee06 100644 --- a/packages/cli/src/shared/spawn-skill.ts +++ b/packages/cli/src/shared/spawn-skill.ts @@ -4,7 +4,7 @@ import type { CloudRunner } from "./agent-setup.js"; -import { wrapSshCall } from "./agent-setup.js"; +import { validateScriptTemplate, wrapSshCall } from "./agent-setup.js"; import { asyncTryCatchIf, isOperationalError } from "./result.js"; import { logInfo, logWarn } from "./ui.js"; @@ -158,6 +158,8 @@ export async function injectSpawnSkill(runner: CloudRunner, agentName: string): return; } + validateScriptTemplate(config.content, `spawn-skill-${agentName}`); + const b64 = Buffer.from(config.content).toString("base64"); if (!/^[A-Za-z0-9+/=]+$/.test(b64)) { throw new Error("Unexpected characters in base64 output");