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>
144 lines
4.8 KiB
JavaScript
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);
|
|
});
|