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
397 changes: 201 additions & 196 deletions cli.bundle.mjs

Large diffs are not rendered by default.

11 changes: 11 additions & 0 deletions hooks/core/routing.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -842,6 +842,11 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform, sessi

// ─── MCP execute: security check for shell commands ───
// Match bare, generic MCP, and legacy context-mode execute tool names.
const shouldPinClaudeExecutorCwd =
platform === "claude-code" &&
typeof projectDir === "string" &&
projectDir.length > 0;

if (matchesContextModeTool(toolName, "ctx_execute", "execute")) {
if (security && toolInput.language === "shell") {
const code = toolInput.code ?? "";
Expand All @@ -856,6 +861,9 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform, sessi
}
}
}
if (toolInput.language === "shell" && shouldPinClaudeExecutorCwd && typeof toolInput.cwd !== "string") {
return { action: "modify", updatedInput: { ...toolInput, cwd: projectDir } };
}
return null;
}

Expand Down Expand Up @@ -907,6 +915,9 @@ export function routePreToolUse(toolName, toolInput, projectDir, platform, sessi
}
}
}
if (shouldPinClaudeExecutorCwd && typeof toolInput.cwd !== "string") {
return { action: "modify", updatedInput: { ...toolInput, cwd: projectDir } };
}
return null;
}

Expand Down
5 changes: 3 additions & 2 deletions hooks/pretooluse.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ await runHook(async () => {
const { readStdin } = await import("./core/stdin.mjs");
const { routePreToolUse, initSecurity } = await import("./core/routing.mjs");
const { formatDecision } = await import("./core/formatters.mjs");
const { parseStdin, getSessionId, resolveConfigDir } = await import("./session-helpers.mjs");
const { parseStdin, getInputProjectDir, getSessionId, resolveConfigDir } = await import("./session-helpers.mjs");

// ─── Manual recursive copy (avoids cpSync libuv crash on non-ASCII paths, Windows + Node 24) ───
function copyDirSync(src, dest) {
Expand Down Expand Up @@ -163,9 +163,10 @@ await runHook(async () => {
const input = parseStdin(raw);
const tool = input.tool_name ?? "";
const toolInput = input.tool_input ?? {};
const projectDir = getInputProjectDir(input);

// ─── Route and format response ───
const decision = routePreToolUse(tool, toolInput, process.env.CLAUDE_PROJECT_DIR, "claude-code", getSessionId(input));
const decision = routePreToolUse(tool, toolInput, projectDir, "claude-code", getSessionId(input));
const response = formatDecision("claude-code", decision);

// ─── Write latency marker for cross-hook timing (Category 27) ───
Expand Down
295 changes: 150 additions & 145 deletions server.bundle.mjs

Large diffs are not rendered by default.

22 changes: 17 additions & 5 deletions src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1267,11 +1267,12 @@ export interface BatchRunOptions {
timeout: number | undefined;
concurrency: number;
nodeOptsPrefix: string;
cwd?: string;
onFsBytes?: (bytes: number) => void;
}

interface BatchExecutor {
execute(input: { language: "shell"; code: string; timeout: number | undefined }): Promise<{ stdout: string; timedOut?: boolean }>;
execute(input: { language: "shell"; code: string; timeout: number | undefined; cwd?: string }): Promise<{ stdout: string; timedOut?: boolean }>;
}

function quotePosixSingle(value: string): string {
Expand Down Expand Up @@ -1372,7 +1373,7 @@ export async function runBatchCommands(
opts: BatchRunOptions,
executor: BatchExecutor,
): Promise<BatchRunResult> {
const { timeout, concurrency, nodeOptsPrefix, onFsBytes } = opts;
const { timeout, concurrency, nodeOptsPrefix, cwd, onFsBytes } = opts;

if (concurrency <= 1) {
// Serial path — shared timeout budget, cascading skip on timeout.
Expand All @@ -1398,6 +1399,7 @@ export async function runBatchCommands(
language: "shell",
code: `${nodeOptsPrefix}${cmd.command}`,
timeout: perCmdTimeout,
cwd,
});
outputs.push(formatCommandOutput(cmd.label, cmd.command, combineExecOutput(result), onFsBytes));
if (result.timedOut) {
Expand All @@ -1420,6 +1422,7 @@ export async function runBatchCommands(
language: "shell",
code: `${nodeOptsPrefix}${cmd.command}`,
timeout,
cwd,
});
// Always route partial output through formatCommandOutput so __CM_FS__
// markers are stripped + counted, even when the command timed out.
Expand Down Expand Up @@ -1524,6 +1527,10 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
.optional()
.default(false)
.describe("Keep process running after timeout (for servers/daemons). Returns partial output without killing the process. IMPORTANT: Do NOT add setTimeout/self-close timers in background scripts — the process must stay alive until the timeout detaches it. For server+fetch patterns, prefer putting both server and fetch in ONE ctx_execute call instead of using background."),
cwd: z
.string()
.optional()
.describe("Optional working directory for shell commands. Non-shell languages still execute from their sandbox temp directory."),
intent: z
.string()
.optional()
Expand All @@ -1535,7 +1542,7 @@ EXAMPLE: ctx_execute(language: "javascript", code: "const out = require('child_p
),
}),
},
async ({ language, code, timeout, background, intent }) => {
async ({ language, code, timeout, background, cwd, intent }) => {
// Security: deny-only firewall
if (language === "shell") {
const denied = checkDenyPolicy(code, "execute");
Expand Down Expand Up @@ -1613,7 +1620,7 @@ ${code}
__cm_main().catch(e=>{console.error(e);process.exitCode=1});${background ? '\nsetInterval(()=>{},2147483647);' : ''}
})(typeof require!=='undefined'?require:null);`;
}
const result = await executor.execute({ language, code: instrumentedCode, timeout, background });
const result = await executor.execute({ language, code: instrumentedCode, timeout, background, cwd });

// Echo the executed source code before stdout so users can audit
// and tooling can block command patterns (Issues #717 + #736).
Expand Down Expand Up @@ -3484,6 +3491,10 @@ EXAMPLE: ctx_batch_execute(
">1 switches to per-command timeouts (no shared budget) and " +
"individual `(timed out)` blocks instead of cascading skip.",
),
cwd: z
.string()
.optional()
.describe("Optional working directory for all shell commands in this batch."),
query_scope: z
.enum(["batch", "global"])
.optional()
Expand All @@ -3498,7 +3509,7 @@ EXAMPLE: ctx_batch_execute(
),
}),
},
async ({ commands, queries, timeout, concurrency, query_scope }) => {
async ({ commands, queries, timeout, concurrency, cwd, query_scope }) => {
// Security: check each command against deny patterns
for (const cmd of commands) {
const denied = checkDenyPolicy(cmd.command, "batch_execute");
Expand All @@ -3519,6 +3530,7 @@ EXAMPLE: ctx_batch_execute(
timeout,
concurrency,
nodeOptsPrefix,
cwd,
onFsBytes: (bytes) => { sessionStats.bytesSandboxed += bytes; },
},
executor,
Expand Down
42 changes: 39 additions & 3 deletions tests/core/server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3227,10 +3227,10 @@ import {
interface MockResult { stdout: string; stderr?: string; timedOut?: boolean; }

function mkMockExecutor(
handler: (code: string, timeout: number | undefined) => Promise<MockResult> | MockResult,
): { execute: (input: { language: "shell"; code: string; timeout: number | undefined }) => Promise<MockResult> } {
handler: (code: string, timeout: number | undefined, cwd: string | undefined) => Promise<MockResult> | MockResult,
): { execute: (input: { language: "shell"; code: string; timeout: number | undefined; cwd?: string }) => Promise<MockResult> } {
return {
execute: async ({ code, timeout }) => Promise.resolve(handler(code, timeout)),
execute: async ({ code, timeout, cwd }) => Promise.resolve(handler(code, timeout, cwd)),
};
}

Expand Down Expand Up @@ -3273,6 +3273,22 @@ describe("runBatchCommands serial path (concurrency=1)", () => {
expect(outputs[0]).toContain("stderr");
});

test("passes cwd override to serial shell executions (#756)", async () => {
const seenCwds: Array<string | undefined> = [];
const exec = mkMockExecutor((_code, _timeout, cwd) => {
seenCwds.push(cwd);
return { stdout: "ok" };
});

await runBatchCommands(
[{ label: "cwd", command: "pwd" }],
{ timeout: 5000, concurrency: 1, nodeOptsPrefix: NOOP_PREFIX, cwd: "/worktree/repo" },
exec,
);

expect(seenCwds).toEqual(["/worktree/repo"]);
});

test("cascading skip: timeout in first cmd skips the rest", async () => {
let callCount = 0;
const exec = mkMockExecutor(() => {
Expand Down Expand Up @@ -3393,6 +3409,26 @@ describe("runBatchCommands parallel path (concurrency>1)", () => {
expect(outputs[1]).toContain("two stderr");
});

test("passes cwd override to parallel shell executions (#756)", async () => {
const seenCwds: Array<string | undefined> = [];
const exec = mkMockExecutor((_code, _timeout, cwd) => {
seenCwds.push(cwd);
return { stdout: "ok" };
});
const cmds: BatchCommand[] = [
{ label: "A", command: "pwd" },
{ label: "B", command: "git branch --show-current" },
];

await runBatchCommands(
cmds,
{ timeout: 5000, concurrency: 2, nodeOptsPrefix: NOOP_PREFIX, cwd: "/worktree/repo" },
exec,
);

expect(seenCwds).toEqual(["/worktree/repo", "/worktree/repo"]);
});

test("order preservation: outputs match input order, not completion order", async () => {
const exec = mkMockExecutor(async (code) => {
// Reverse-order delay: first cmd is slowest
Expand Down
39 changes: 39 additions & 0 deletions tests/hooks/core-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -514,6 +514,45 @@ describe("routePreToolUse", () => {
);
expect(result).toBeNull();
});

it("pins Claude Code ctx_execute shell cwd from hook projectDir (#756)", () => {
const result = routePreToolUse(
"mcp__plugin_context-mode_context-mode__ctx_execute",
{ language: "shell", code: "pwd" },
"/worktree/repo",
"claude-code",
);
expect(result?.action).toBe("modify");
expect(result?.updatedInput).toEqual({
language: "shell",
code: "pwd",
cwd: "/worktree/repo",
});
});

it("pins Claude Code ctx_batch_execute cwd from hook projectDir (#756)", () => {
const result = routePreToolUse(
"mcp__plugin_context-mode_context-mode__ctx_batch_execute",
{ commands: [{ label: "branch", command: "git rev-parse --abbrev-ref HEAD" }] },
"/worktree/repo",
"claude-code",
);
expect(result?.action).toBe("modify");
expect(result?.updatedInput).toEqual({
commands: [{ label: "branch", command: "git rev-parse --abbrev-ref HEAD" }],
cwd: "/worktree/repo",
});
});

it("does not overwrite explicit ctx_execute cwd", () => {
const result = routePreToolUse(
"mcp__plugin_context-mode_context-mode__ctx_execute",
{ language: "shell", code: "pwd", cwd: "/explicit" },
"/worktree/repo",
"claude-code",
);
expect(result).toBeNull();
});
});

describe("Codex context-mode MCP execute security", () => {
Expand Down
57 changes: 43 additions & 14 deletions tests/hooks/integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -517,6 +517,28 @@ describe("Security Policy Enforcement", () => {
assert.equal(result.stdout, "", "Non-shell language should passthrough");
});

test("MCP execute + shell pins cwd from hook input over stale CLAUDE_PROJECT_DIR (#756)", () => {
const mainRepo = mkdtempSync(join(tmpdir(), "ctx-756-main-"));
const worktree = mkdtempSync(join(tmpdir(), "ctx-756-worktree-"));
try {
const result = runHook(
{
cwd: worktree,
tool_name: "mcp__plugin_context-mode_context-mode__ctx_execute",
tool_input: { language: "shell", code: "pwd" },
},
{ ...secEnv, CLAUDE_PROJECT_DIR: mainRepo },
);
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.hookSpecificOutput.updatedInput.cwd, worktree);
assert.equal(parsed.hookSpecificOutput.updatedInput.code, "pwd");
} finally {
rmSyncRobust(mainRepo);
rmSyncRobust(worktree);
}
});

test("Security: MCP execute_file + .env path denied", () => {
const result = runHook(
{
Expand Down Expand Up @@ -574,21 +596,28 @@ describe("Security Policy Enforcement", () => {
assert.equal(parsed.hookSpecificOutput.permissionDecision, "deny");
});

test("Security: MCP batch_execute with all allowed commands passthrough", () => {
const result = runHook(
{
tool_name: "mcp__plugin_context-mode_context-mode__ctx_batch_execute",
tool_input: {
commands: [
{ label: "list", command: "ls -la" },
{ label: "git", command: "git log --oneline -5" },
],
test("MCP batch_execute with allowed commands pins cwd from hook input (#756)", () => {
const worktree = mkdtempSync(join(tmpdir(), "ctx-756-batch-worktree-"));
try {
const result = runHook(
{
cwd: worktree,
tool_name: "mcp__plugin_context-mode_context-mode__ctx_batch_execute",
tool_input: {
commands: [
{ label: "list", command: "ls -la" },
{ label: "git", command: "git log --oneline -5" },
],
},
},
},
secEnv,
);
assert.equal(result.exitCode, 0);
assert.equal(result.stdout, "", "All allowed commands should passthrough");
secEnv,
);
assert.equal(result.exitCode, 0);
const parsed = JSON.parse(result.stdout);
assert.equal(parsed.hookSpecificOutput.updatedInput.cwd, worktree);
} finally {
rmSyncRobust(worktree);
}
});
});

Expand Down
Loading