diff --git a/plugins/relay/scripts/hooks/relay-guard.mjs b/plugins/relay/scripts/hooks/relay-guard.mjs index 4b8d9796..612d25eb 100644 --- a/plugins/relay/scripts/hooks/relay-guard.mjs +++ b/plugins/relay/scripts/hooks/relay-guard.mjs @@ -62,6 +62,14 @@ import { SCHEMA_SENTENCE } from "../lib/harness/workflow.mjs"; const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); const PLUGIN_ROOT = path.resolve(SCRIPT_DIR, "../.."); const COMPANION = path.join(SCRIPT_DIR, "..", "companion.mjs"); + +/** + * A path with its symlinks resolved. Node gives this module its real path, while Claude Code may run the hook and + * the relay may name the companion through a symlinked plugin folder; paths are compared resolved. + */ +function realPath(file) { + try { return fs.realpathSync(file); } catch { return path.resolve(file); } +} const SUBCOMMANDS = new Set(["start", "wait", "result", "status", "cancel"]); const JOB_ID = /^[0-9A-Za-z][0-9A-Za-z._-]*$/; const SHOWN = 'node "${CLAUDE_PLUGIN_ROOT}/scripts/companion.mjs"'; @@ -112,7 +120,7 @@ export function parseCompanionCommand(command) { return null; } const script = tokens[1].replace("${CLAUDE_PLUGIN_ROOT}", PLUGIN_ROOT); - if (path.resolve(script) !== COMPANION) { + if (realPath(script) !== realPath(COMPANION)) { return null; } const subcommand = tokens[2]; @@ -465,7 +473,7 @@ async function main() { } } -if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) { +if (process.argv[1] && realPath(process.argv[1]) === realPath(fileURLToPath(import.meta.url))) { main().catch((error) => { // Fail open: a broken hook must never trap the relay or any other agent. process.stderr.write(`relay guard: ${error instanceof Error ? error.stack ?? error.message : String(error)}\n`); diff --git a/tests/relay-guard.test.mjs b/tests/relay-guard.test.mjs index 7c0bb186..7308f116 100644 --- a/tests/relay-guard.test.mjs +++ b/tests/relay-guard.test.mjs @@ -1287,3 +1287,19 @@ test("a relay that never runs start gets its job started by the hook: at its han assert.equal(updated(handback).message, resultOf(ctx, jobId), `${what}: the hand-back is the job's result`); } }); + +test("a plugin reached through a symlinked folder: the hook runs, and knows the companion by either path", () => { + // Claude Code can install a plugin through a symlinked config folder. Node gives the hook its real path, while the + // hook is run and the relay names the companion through the symlink. + const link = path.join(makeTempDir("relay-link-"), "plugin"); + fs.symlinkSync(PLUGIN_ROOT, link); + assert.deepEqual(parseCompanionCommand(`node "${path.join(link, "scripts", "companion.mjs")}" start --from-relay`), + { subcommand: "start", jobId: null, fromRelay: true }); + + const ctx = relayContext("job"); + const input = hookInput(ctx, "PreToolUse", { tool_name: "Bash", tool_input: { command: "sw_vers -productVersion" } }); + const r = spawnSync("node", [path.join(link, "scripts", "hooks", "relay-guard.mjs")], + { input: JSON.stringify(input), env: ctx.env, encoding: "utf8" }); + assert.equal(r.status, 0, r.stderr); + assert.equal(JSON.parse(r.stdout).hookSpecificOutput.permissionDecision, "deny"); +});