Skip to content
Open
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
15 changes: 10 additions & 5 deletions packages/pi-hypa/extensions/tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ const shellSchema = {
properties: {
command: textParameter("Shell command to execute through Hypa compression"),
timeoutMs: numberParameter("Timeout in milliseconds (default: Hypa CLI default)"),
raw: booleanParameter("Run with hypa raw instead of compressed hypa -c"),
raw: booleanParameter("Run a simple whitespace-tokenized command with hypa raw; omit for shell syntax, quoting, or multiline commands"),
},
required: ["command"],
additionalProperties: false,
Expand Down Expand Up @@ -461,10 +461,14 @@ async function runHypaCommand(
return pi.exec(execBin, execArgs, { signal, timeout: timeoutMs });
}

function splitRawCommand(command: string): string[] {
// Raw mode is intentionally conservative: pass through simple whitespace-tokenized commands only.
// Complex shell syntax should use compressed mode, where Hypa owns shell parsing.
return command.trim().split(/\s+/).filter(Boolean);
export function splitRawCommand(command: string): string[] {
const trimmed = command.trim();
if (!trimmed || /[\r\n'"\\`$%&|;<>^!()]/.test(trimmed)) {
throw new Error(
"hypa_shell raw mode only supports a simple executable and whitespace-separated arguments; omit raw for shell syntax, quoting, or multiline commands",
);
}
return trimmed.split(/\s+/);
}

function hasOwn(obj: unknown, key: string): boolean {
Expand Down Expand Up @@ -606,6 +610,7 @@ export function registerHypaTools(pi: PiApi, config: HypaPiConfig) {
promptSnippet: "Run shell commands through Hypa compression",
promptGuidelines: [
"Use hypa_shell for shell commands when compressed output is preferred.",
"Use raw mode only for a simple executable and whitespace-separated arguments; omit it for shell syntax, quoting, or multiline commands.",
"Do not use hypa_shell to read files; use hypa_read instead.",
],
parameters: shellSchema,
Expand Down
27 changes: 27 additions & 0 deletions packages/pi-hypa/test/tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import {
limitStdoutLines,
looksLikeOpaqueBinary,
shellQuote,
splitRawCommand,
tryBuildImageReadResult,
} from "../extensions/tools.js";

Expand All @@ -34,6 +35,32 @@ test("shellQuote uses cmd-style double quotes on Windows", () => {
// outside this function's scope; do not use MSVC list2cmdline escaping here.
});

test("splitRawCommand accepts simple executable arguments", () => {
assert.deepEqual(splitRawCommand(" npm test -- --runInBand "), ["npm", "test", "--", "--runInBand"]);
assert.deepEqual(splitRawCommand("rg foo.* src"), ["rg", "foo.*", "src"]);
});

test("splitRawCommand rejects syntax that requires a shell", () => {
for (const command of [
"mkdir fixture && cd fixture",
"test -f file || echo missing",
"cat file | grep text",
"echo text > file",
"echo $HOME",
"echo %PATH%",
"echo hello^world",
"echo hello!",
"echo 'two words'",
'echo "two words"',
"printf foo\\n",
"echo one\necho two",
"(echo text)",
"",
]) {
assert.throws(() => splitRawCommand(command), /omit raw for shell syntax, quoting, or multiline commands/);
}
});

test("buildReadCommand uses cat by default and sed for line slices", () => {
const home = homedir();
assert.equal(buildReadCommand("src/File.cs"), "cat -- src/File.cs");
Expand Down