diff --git a/bin/moshcode.mjs b/bin/moshcode.mjs index c1d3f48..c28a940 100755 --- a/bin/moshcode.mjs +++ b/bin/moshcode.mjs @@ -224,7 +224,10 @@ async function main() { } if (cmd === "install") { const target = rest.find((a) => !a.startsWith("-"))?.toLowerCase(); - const entry = target && (ENGINES[target] || TOOLS[target]); + // Own properties only — `install constructor` must print usage, not resolve + // to something off Object.prototype and crash on its missing install spec. + const entry = target + && ((Object.hasOwn(ENGINES, target) && ENGINES[target]) || (Object.hasOwn(TOOLS, target) && TOOLS[target])); if (!target || !entry) { console.error(`usage: moshcode install \nengines:\n${engineList()}\ntools:\n${toolList()}`); process.exit(target ? 1 : 0); diff --git a/src/engines.mjs b/src/engines.mjs index d4e72d0..b42851c 100644 --- a/src/engines.mjs +++ b/src/engines.mjs @@ -78,7 +78,10 @@ const ALIASES = { cc: "claude", "claude-code": "claude", openai: "codex", gpt: " export function resolveEngine(token) { if (!token) return null; const t = String(token).trim().toLowerCase(); - const key = ENGINES[t] ? t : ALIASES[t]; + // Own properties only: ENGINES/ALIASES are plain object literals, so a name + // like `constructor` or `__proto__` would otherwise resolve to something off + // Object.prototype and be handed on as an engine with no bin/install. + const key = Object.hasOwn(ENGINES, t) ? t : Object.hasOwn(ALIASES, t) ? ALIASES[t] : null; return key ? [key, ENGINES[key]] : null; } @@ -145,7 +148,7 @@ const AI_EXEC = { /** argv that runs `prompt` headlessly on `engine` (throws if it has no headless mode). */ export function aiExecArgs(engine, prompt) { - const fn = AI_EXEC[engine]; + const fn = Object.hasOwn(AI_EXEC, engine) ? AI_EXEC[engine] : null; if (!fn) throw new Error(`moshscript: ai() has no headless mode for "${engine}"`); return fn(String(prompt)); } @@ -154,7 +157,7 @@ export function aiExecArgs(engine, prompt) { export function pickAiEngine(preferred) { const order = preferred ? [preferred] : ["claude", "codex", "opencode", "gemini", "aider"]; for (const key of order) { - if (ENGINES[key] && AI_EXEC[key] && isInstalled(ENGINES[key].bin)) return key; + if (Object.hasOwn(ENGINES, key) && Object.hasOwn(AI_EXEC, key) && isInstalled(ENGINES[key].bin)) return key; } return null; } diff --git a/src/tools.mjs b/src/tools.mjs index f52c308..e0848da 100644 --- a/src/tools.mjs +++ b/src/tools.mjs @@ -38,7 +38,10 @@ export const TOOLS = { export function resolveTool(token) { if (!token) return null; const key = String(token).trim().toLowerCase(); - return TOOLS[key] ? [key, TOOLS[key]] : null; + // Own properties only: TOOLS is a plain object literal, so a name like + // `constructor` or `__proto__` would otherwise resolve to something off + // Object.prototype and be handed on as a tool with no bin/install. + return Object.hasOwn(TOOLS, key) ? [key, TOOLS[key]] : null; } /** Tool entries annotated with native executable install status. */ diff --git a/src/tui.mjs b/src/tui.mjs index e5374af..ab9b08f 100644 --- a/src/tui.mjs +++ b/src/tui.mjs @@ -252,7 +252,9 @@ async function openShell(rawCmd) { function installTarget(key) { return new Promise((resolve) => { - const target = ENGINES[key] || TOOLS[key]; + // Own properties only — `/install constructor` must print the unknown-target + // line, not resolve to something off Object.prototype and crash the pit. + const target = (Object.hasOwn(ENGINES, key) && ENGINES[key]) || (Object.hasOwn(TOOLS, key) && TOOLS[key]); if (!target) { console.log(err(`unknown engine or tool "${key}"`)); return resolve(); } console.log(info(`installing ${key}: ${target.install.cmd} ${target.install.args.join(" ")}`)); console.log(hr()); diff --git a/test/engines.test.mjs b/test/engines.test.mjs index 54d47db..437e77d 100644 --- a/test/engines.test.mjs +++ b/test/engines.test.mjs @@ -14,7 +14,7 @@ import { fileURLToPath } from "node:url"; import { spawn } from "node:child_process"; import test from "node:test"; -import { ENGINES, agentLaunchArgs, exitReason, ranOk, runCmd } from "../src/engines.mjs"; +import { ENGINES, agentLaunchArgs, aiExecArgs, exitReason, pickAiEngine, ranOk, resolveEngine, runCmd } from "../src/engines.mjs"; const BIN = fileURLToPath(new URL("../bin/moshcode.mjs", import.meta.url)); // The autonomous-session bypass flags each engine declares (engine.agentArgs). @@ -157,3 +157,12 @@ test("ranOk and exitReason cover clean exits, bad codes, and spawn errors", asyn assert.equal(ranOk(missing), false); assert.match(exitReason(missing), /ENOENT|not found|spawn/i); }); + +test("engine lookup ignores inherited Object.prototype members", () => { + // ENGINES/ALIASES/AI_EXEC are plain object literals, so an unknown name that + // matches an Object.prototype member must not resolve as a real engine. + assert.equal(resolveEngine("constructor"), null); + assert.equal(resolveEngine("__proto__"), null); + assert.equal(pickAiEngine("constructor"), null); + assert.throws(() => aiExecArgs("constructor", "hi"), /no headless mode for "constructor"/); +}); diff --git a/test/tools.test.mjs b/test/tools.test.mjs index 8286b7c..9e6ec7c 100644 --- a/test/tools.test.mjs +++ b/test/tools.test.mjs @@ -184,3 +184,19 @@ fs.writeFileSync(process.env.SHELL_CAPTURE, JSON.stringify(process.argv.slice(2) assert.deepEqual(JSON.parse(readFileSync(capture, "utf8")), ["-c", script]); }); } + +test("tool lookup ignores inherited Object.prototype members", () => { + // TOOLS is a plain object literal: `TOOLS.constructor` is truthy but is not a + // tool, so resolving it would hand a bin-less, install-less entry downstream. + assert.equal(resolveTool("constructor"), null); + assert.equal(resolveTool("__proto__"), null); + assert.equal(resolveTool("valueOf"), null); +}); + +test("moshcode install reports an Object.prototype name as unknown", async () => { + const result = await run(["install", "constructor"]); + + assert.equal(result.status, 1); + assert.match(result.stderr, /usage: moshcode install /); + assert.doesNotMatch(result.stderr, /TypeError/); +}); diff --git a/test/tui.test.mjs b/test/tui.test.mjs index a3a4de9..42b9214 100644 --- a/test/tui.test.mjs +++ b/test/tui.test.mjs @@ -105,3 +105,15 @@ test("TUI /shell and !cmd run an identical command identically", posixShell, asy assert.equal(viaBang.status, 0); assert.deepEqual(bracketed(viaSlash.stdout), bracketed(viaBang.stdout)); }); + +// ENGINES/TOOLS are plain object literals, so an unknown target that happens to +// name an Object.prototype member used to resolve truthy and reach `.install`, +// killing the whole pit with a raw TypeError instead of printing the usual +// unknown-target line. +test("TUI /install rejects an Object.prototype name instead of crashing the pit", async () => { + const result = await runTui("/install constructor\n/quit\n"); + + assert.equal(result.status, 0, result.stderr || result.stdout); + assert.match(result.stdout, /unknown engine or tool "constructor"/); + assert.doesNotMatch(result.stderr, /TypeError/); +});