Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion bin/moshcode.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <engine|tool>\nengines:\n${engineList()}\ntools:\n${toolList()}`);
process.exit(target ? 1 : 0);
Expand Down
9 changes: 6 additions & 3 deletions src/engines.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down Expand Up @@ -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));
}
Expand All @@ -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;
}
Expand Down
5 changes: 4 additions & 1 deletion src/tools.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
4 changes: 3 additions & 1 deletion src/tui.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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());
Expand Down
11 changes: 10 additions & 1 deletion test/engines.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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"/);
});
16 changes: 16 additions & 0 deletions test/tools.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <engine\|tool>/);
assert.doesNotMatch(result.stderr, /TypeError/);
});
12 changes: 12 additions & 0 deletions test/tui.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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/);
});
Loading