Skip to content
Closed
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
12 changes: 10 additions & 2 deletions plugins/relay/scripts/hooks/relay-guard.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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"';
Expand Down Expand Up @@ -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];
Expand Down Expand Up @@ -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`);
Expand Down
16 changes: 16 additions & 0 deletions tests/relay-guard.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
Loading