From 40c09a03451d0dee8b9988342075b60b87088b19 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Darko=20Miji=C4=87?= Date: Thu, 29 Oct 2026 06:18:51 +0100 Subject: [PATCH] relay-guard: compare resolved paths, so a symlinked plugin folder works When Claude Code installs the plugin through a symlinked config folder, it runs the hook and the relay names the companion through the symlink, while Node gives the hook module its real path. The hook's entry check then never ran main(), and start --from-relay failed with 'the hook did not run'. Both the entry check and the companion match now compare paths with symlinks resolved. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Cw9dodEyoUpvSEJQUeF1FJ --- plugins/relay/scripts/hooks/relay-guard.mjs | 12 ++++++++++-- tests/relay-guard.test.mjs | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 2 deletions(-) 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"); +});