ktg-plugin-marketplace/plugins/llm-security-copilot/hooks/scripts/copilot-hook-runner.mjs
Kjell Tore Guttormsen f418a8fe08 feat(llm-security-copilot): port llm-security v5.1.0 to GitHub Copilot CLI
Full port of llm-security plugin for internal use on Windows with GitHub
Copilot CLI. Protocol translation layer (copilot-hook-runner.mjs)
normalizes Copilot camelCase I/O to Claude Code snake_case format — all
original hook scripts run unmodified.

- 8 hooks with protocol translation (stdin/stdout/exit code)
- 18 SKILL.md skills (Agent Skills Open Standard)
- 6 .agent.md agent definitions
- 20 scanners + 14 scanner lib modules (unchanged)
- 14 knowledge files (unchanged)
- 39 test files including copilot-port-verify.mjs (17 tests)
- Windows-ready: node:path, os.tmpdir(), process.execPath, no bash

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-09 21:56:10 +02:00

144 lines
4.8 KiB
JavaScript

#!/usr/bin/env node
// copilot-hook-runner.mjs — Protocol translator between Copilot CLI and Claude Code hooks.
//
// Copilot CLI sends: { toolName, toolArgs, toolResult, message, sessionId }
// Claude Code hooks expect: { tool_name, tool_input, tool_output, message, session_id }
//
// Claude Code hooks output: { decision: 'block'|'allow', reason, systemMessage }
// Copilot CLI expects: { permissionDecision: 'deny'|'allow', reason, message }
//
// Usage: node copilot-hook-runner.mjs <hook-script.mjs>
// The wrapper reads stdin, normalizes field names, spawns the original hook
// with normalized stdin, captures output, translates back to Copilot format,
// and preserves the exit code.
import { readFileSync } from 'node:fs';
import { spawn } from 'node:child_process';
import { resolve, dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const pluginRoot = resolve(__dirname, '..', '..');
// Resolve hook path
const hookArg = process.argv[2];
if (!hookArg) {
process.stderr.write('copilot-hook-runner: missing hook argument\n');
process.exit(0); // fail-open
}
const hookPath = resolve(__dirname, hookArg);
// --- Step 1: Read and normalize stdin (Copilot → Claude Code) ---
let normalizedStdin;
try {
const raw = readFileSync(0, 'utf-8');
const obj = JSON.parse(raw);
// Normalize Copilot camelCase → Claude Code snake_case
if (obj.toolName !== undefined && obj.tool_name === undefined)
obj.tool_name = obj.toolName;
if (obj.toolArgs !== undefined && obj.tool_input === undefined)
obj.tool_input = obj.toolArgs;
if (obj.toolResult !== undefined && obj.tool_output === undefined)
obj.tool_output = obj.toolResult;
if (obj.sessionId !== undefined && obj.session_id === undefined)
obj.session_id = obj.sessionId;
// For userPromptSubmitted: normalize prompt field variations.
// Claude Code hook expects: { message: { role, content }, prompt (fallback) }
// Copilot may send: { message: "text" } or { prompt: "text" }
if (typeof obj.message === 'string' && obj.tool_name === undefined) {
const text = obj.message;
obj.message = { role: 'user', content: text };
if (obj.prompt === undefined) obj.prompt = text;
}
// Also normalize nested tool_input field names for Copilot
// Copilot uses: { toolArgs: { command, filePath, content } }
// Claude Code uses: { tool_input: { command, file_path, content, new_string } }
const ti = obj.tool_input;
if (ti && typeof ti === 'object') {
if (ti.filePath !== undefined && ti.file_path === undefined)
ti.file_path = ti.filePath;
if (ti.newString !== undefined && ti.new_string === undefined)
ti.new_string = ti.newString;
}
normalizedStdin = JSON.stringify(obj);
} catch {
normalizedStdin = '{}'; // fail-open: hook will get empty input and exit 0
}
// --- Step 2: Spawn the original hook ---
const env = {
...process.env,
CLAUDE_PLUGIN_ROOT: pluginRoot,
CLAUDE_WORKING_DIR: process.env.COPILOT_WORKING_DIR || process.cwd(),
};
const child = spawn(process.execPath, [hookPath], {
stdio: ['pipe', 'pipe', 'pipe'],
env,
// Windows: no shell needed, node handles it
});
// Feed normalized stdin
child.stdin.write(normalizedStdin);
child.stdin.end();
// --- Step 3: Capture output ---
let stdoutData = '';
let stderrData = '';
child.stdout.on('data', (chunk) => { stdoutData += chunk; });
child.stderr.on('data', (chunk) => {
stderrData += chunk;
process.stderr.write(chunk); // pass stderr through immediately
});
child.on('error', () => {
process.exit(0); // fail-open on spawn error
});
child.on('exit', (code) => {
// --- Step 4: Translate output (Claude Code → Copilot) ---
const exitCode = code ?? 0;
if (stdoutData.trim()) {
try {
const out = JSON.parse(stdoutData);
// Translate decision → permissionDecision
if (out.decision === 'block') {
out.permissionDecision = 'deny';
delete out.decision;
} else if (out.decision === 'allow') {
out.permissionDecision = 'allow';
delete out.decision;
}
// Translate systemMessage → message
if (out.systemMessage !== undefined) {
out.message = out.message || out.systemMessage;
delete out.systemMessage;
}
process.stdout.write(JSON.stringify(out));
} catch {
// Not JSON — pass through as-is
process.stdout.write(stdoutData);
}
} else if (exitCode === 2 && stderrData.trim()) {
// Hook wrote to stderr only (e.g. pre-edit-secrets, pre-write-pathguard).
// Generate Copilot-format JSON from stderr message + exit code.
const msg = stderrData.trim().split('\n')[0]; // first line is the summary
process.stdout.write(JSON.stringify({
permissionDecision: 'deny',
message: stderrData.trim(),
reason: msg,
}));
}
process.exit(exitCode);
});