fix: address PR #4180 review - security, typing, and test coverage
- Apply mcp_env_allowlist to plugin hooks: intersect HTTP allowedEnvVars with MCP allowlist, set command allowedEnvVars to full MCP allowlist - Scrub process.env in executeHookCommand when allowedEnvVars provided - Add PluginHooksState class with per-directory Map storage - Add PluginHooksConfig interface for typed boundary layer - Pass directory context through hook-config-handler - Add 16 tests across 4 files (40 assertions) covering allowlist filtering, env scrubbing, directory isolation, and edge cases - Remove unnecessary 'as' type assertions, use discriminated union narrowing instead
This commit is contained in:
@@ -0,0 +1,62 @@
|
||||
const { afterEach, beforeEach, describe, expect, test } = require("bun:test")
|
||||
import { tmpdir } from "node:os"
|
||||
import { join } from "node:path"
|
||||
import { mkdtempSync, rmSync } from "node:fs"
|
||||
|
||||
const { executeHookCommand } = await import("./execute-hook-command")
|
||||
|
||||
describe("executeHookCommand", () => {
|
||||
let tempDirectory = ""
|
||||
|
||||
beforeEach(() => {
|
||||
tempDirectory = mkdtempSync(join(tmpdir(), "omo-exec-hook-cmd-"))
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(tempDirectory, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
test("#given allowedEnvVars provided #when executing command #then only allowed vars are in process.env", async () => {
|
||||
// given
|
||||
process.env.__OMO_TEST_ALLOWED_VAR = "visible"
|
||||
process.env.__OMO_TEST_SECRET_VAR = "hidden"
|
||||
|
||||
// when
|
||||
const result = await executeHookCommand(
|
||||
"echo $__OMO_TEST_ALLOWED_VAR $__OMO_TEST_SECRET_VAR",
|
||||
"",
|
||||
tempDirectory,
|
||||
{ allowedEnvVars: ["__OMO_TEST_ALLOWED_VAR"] },
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("visible")
|
||||
expect(result.stdout).not.toContain("hidden")
|
||||
|
||||
// cleanup
|
||||
delete process.env.__OMO_TEST_ALLOWED_VAR
|
||||
delete process.env.__OMO_TEST_SECRET_VAR
|
||||
})
|
||||
|
||||
test("#given no allowedEnvVars #when executing command #then full env is available", async () => {
|
||||
// given
|
||||
process.env.__OMO_TEST_FULL_ENV_VAR = "present"
|
||||
|
||||
// when
|
||||
const result = await executeHookCommand(
|
||||
"echo $__OMO_TEST_FULL_ENV_VAR",
|
||||
"",
|
||||
tempDirectory,
|
||||
)
|
||||
|
||||
// then
|
||||
expect(result.exitCode).toBe(0)
|
||||
expect(result.stdout).toContain("present")
|
||||
|
||||
// cleanup
|
||||
delete process.env.__OMO_TEST_FULL_ENV_VAR
|
||||
})
|
||||
})
|
||||
|
||||
export {}
|
||||
@@ -16,6 +16,8 @@ export interface ExecuteHookOptions {
|
||||
zshPath?: string;
|
||||
/** Timeout in milliseconds. Process is killed after this. Default: 30000 */
|
||||
timeoutMs?: number;
|
||||
/** When provided, scrub process.env to only include these vars plus HOME/PATH/etc. Used for plugin-sourced hooks. */
|
||||
allowedEnvVars?: string[];
|
||||
}
|
||||
|
||||
export async function executeHookCommand(
|
||||
@@ -53,11 +55,25 @@ export async function executeHookCommand(
|
||||
let killTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const isWin32 = process.platform === "win32";
|
||||
|
||||
let env: Record<string, string | undefined>;
|
||||
if (options?.allowedEnvVars) {
|
||||
const allowedSet = new Set(options.allowedEnvVars);
|
||||
env = { HOME: home, CLAUDE_PROJECT_DIR: cwd };
|
||||
for (const key of Object.keys(process.env)) {
|
||||
if (allowedSet.has(key)) {
|
||||
env[key] = process.env[key];
|
||||
}
|
||||
}
|
||||
} else {
|
||||
env = { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd };
|
||||
}
|
||||
|
||||
const proc = spawn(finalCommand, {
|
||||
cwd,
|
||||
shell: true,
|
||||
detached: !isWin32,
|
||||
env: { ...process.env, HOME: home, CLAUDE_PROJECT_DIR: cwd },
|
||||
env,
|
||||
});
|
||||
|
||||
let stdout = "";
|
||||
|
||||
Reference in New Issue
Block a user