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:
JacobZyy
2026-05-20 22:30:25 +08:00
parent 5e20842262
commit 0a20844bd4
11 changed files with 535 additions and 17 deletions
@@ -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 = "";