Files
orca/src/main/copilot/hook-service.ts
T
4797cf81a4 fix(agent-hooks): stream hook payload to a temp file instead of inlining it on the curl command line (#4475)
The POSIX agent-hook script for every curl-based agent inlined the full
event payload via `curl --data-urlencode "payload=${payload}"`. Tool
output can be tens of KB, so the resulting process command line could be
multi-KB — which endpoint security tools (e.g. Microsoft Defender for
Endpoint) flag as an oversized/suspicious command line. That produced a
false-positive detection on Orca's own loopback (127.0.0.1) telemetry POST.

Stream the payload to an mktemp file and post it with
`--data-urlencode "payload@$payload_file"` instead. The urlencoded body on
the wire is byte-identical, so the agent-hook receiver is unchanged; the
payload simply never appears on a process command line. `trap ... EXIT`
removes the temp file on every exit path. Small bounded metadata fields
(paneKey/tabId/worktreeId/env/version) stay inline.

Applied to all curl-based agents: claude, codex, command-code, copilot,
cursor, droid, gemini, grok, antigravity. (amp/hermes/opencode post via
the HTTP request body and were never affected.) The Windows post-command
shares the same latent pattern and is flagged as follow-up.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: Neil <4138956+nwparker@users.noreply.github.com>
Co-authored-by: Orca <help@stably.ai>
2026-07-03 16:26:53 -07:00

416 lines
15 KiB
TypeScript

/* eslint-disable max-lines -- Why: local status/install/remove and SSH remote
install must share the same Copilot event list, script body, and
managed-command matching so local and remote hook behavior cannot drift. */
import { existsSync } from 'node:fs'
import { homedir } from 'node:os'
import { join } from 'node:path'
import type { SFTPWrapper } from 'ssh2'
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
import {
createManagedCommandMatcher,
getSharedManagedScriptPath,
readHooksJson,
removeManagedCommands,
wrapPosixHookCommand,
writeHooksJson,
writeManagedScript,
type HookDefinition
} from '../agent-hooks/installer-utils'
import {
readHooksJsonRemote,
writeHooksJsonRemote,
writeManagedScriptRemote
} from '../agent-hooks/installer-utils-remote'
// Why: Copilot's user-level hook files can use VS Code-compatible PascalCase
// names, which match the event vocabulary already normalized by Orca's hook
// server and avoid wrapper-side event remapping.
const COPILOT_EVENTS = [
'SessionStart',
'SessionEnd',
'UserPromptSubmit',
'PreToolUse',
'PostToolUse',
'PostToolUseFailure',
// Why: GitHub's current reference documents subagentStart with only the
// camelCase payload shape. The wrapper passes the event name separately, so
// Orca can normalize it without depending on a PascalCase payload.
'subagentStart',
'SubagentStop',
'PreCompact',
'Stop',
'ErrorOccurred',
'PermissionRequest',
'Notification'
] as const
function getCopilotHome(): string {
const fromEnv = process.env.COPILOT_HOME?.trim()
return fromEnv ? fromEnv : join(homedir(), '.copilot')
}
function getConfigPath(): string {
return join(getCopilotHome(), 'hooks', 'orca.json')
}
function getManagedScriptFileName(): string {
return process.platform === 'win32' ? 'copilot-hook.ps1' : 'copilot-hook.sh'
}
function getManagedScriptPath(): string {
return getSharedManagedScriptPath(getManagedScriptFileName())
}
function quotePowerShellPath(path: string): string {
return `'${path.replaceAll("'", "''")}'`
}
function getManagedCommand(scriptPath: string, eventName: string): string {
return process.platform === 'win32'
? `$env:ORCA_COPILOT_HOOK_EVENT = '${eventName}'; powershell.exe -NoProfile -ExecutionPolicy Bypass -File ${quotePowerShellPath(scriptPath)}`
: wrapPosixHookCommand(scriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
}
function getManagedHookDefinition(command: string): HookDefinition {
return process.platform === 'win32'
? { type: 'command', powershell: command, timeoutSec: 5 }
: { type: 'command', bash: command, timeoutSec: 5 }
}
function getRemoteManagedHookDefinition(command: string): HookDefinition {
return { type: 'command', bash: command, timeoutSec: 5 }
}
function definitionHasCurrentCommand(definition: HookDefinition, command: string): boolean {
return (
definition.command === command ||
definition.bash === command ||
definition.powershell === command ||
(Array.isArray(definition.hooks) && definition.hooks.some((hook) => hook.command === command))
)
}
function definitionHasStaleManagedCommand(
definition: HookDefinition,
currentCommand: string | null,
isManagedCommand: (command: string | undefined) => boolean
): boolean {
const commands = [definition.command, definition.bash, definition.powershell]
if (commands.some((command) => isManagedCommand(command) && command !== currentCommand)) {
return true
}
return (
Array.isArray(definition.hooks) &&
definition.hooks.some(
(hook) => isManagedCommand(hook.command) && hook.command !== currentCommand
)
)
}
function definitionsChanged(before: HookDefinition[], after: HookDefinition[]): boolean {
return (
before.length !== after.length ||
after.some((definition, index) => definition !== before[index])
)
}
function getManagedScript(target: 'local' | 'posix' = 'local'): string {
if (target === 'local' && process.platform === 'win32') {
return [
"Write-Output '{}'",
// Why: endpoint.cmd is cmd syntax, not PowerShell. Parse its `set KEY=...`
// lines so surviving PTYs can refresh to the current Orca server.
'if ($env:ORCA_AGENT_HOOK_ENDPOINT -and (Test-Path -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT)) {',
' try {',
' Get-Content -LiteralPath $env:ORCA_AGENT_HOOK_ENDPOINT | ForEach-Object {',
" if ($_ -match '^set ([A-Za-z0-9_]+)=(.*)$') {",
" [Environment]::SetEnvironmentVariable($matches[1], $matches[2], 'Process')",
' }',
' }',
' } catch {}',
'}',
'if (-not $env:ORCA_AGENT_HOOK_PORT -or -not $env:ORCA_AGENT_HOOK_TOKEN -or -not $env:ORCA_PANE_KEY) { exit 0 }',
'$inputData = [Console]::In.ReadToEnd()',
'if ([string]::IsNullOrWhiteSpace($inputData)) { exit 0 }',
'try {',
' $payload = $inputData | ConvertFrom-Json',
' $body = @{',
' paneKey = $env:ORCA_PANE_KEY',
' launchToken = $env:ORCA_AGENT_LAUNCH_TOKEN',
' tabId = $env:ORCA_TAB_ID',
' worktreeId = $env:ORCA_WORKTREE_ID',
' hookEventName = $env:ORCA_COPILOT_HOOK_EVENT',
' env = $env:ORCA_AGENT_HOOK_ENV',
' version = $env:ORCA_AGENT_HOOK_VERSION',
' payload = $payload',
' } | ConvertTo-Json -Depth 100',
" Invoke-WebRequest -UseBasicParsing -Method Post -Uri ('http://127.0.0.1:' + $env:ORCA_AGENT_HOOK_PORT + '/hook/copilot') -Headers @{ 'Content-Type'='application/json'; 'X-Orca-Agent-Hook-Token'=$env:ORCA_AGENT_HOOK_TOKEN } -Body $body -TimeoutSec 2 | Out-Null",
'} catch {}',
'exit 0',
''
].join('\r\n')
}
return [
'#!/bin/sh',
"printf '{}\\n'",
// Why: Copilot consumes stdout for some hooks, so stdout is emitted before
// endpoint refresh, stdin parsing, or the network POST can fail.
'if [ -n "$ORCA_AGENT_HOOK_ENDPOINT" ] && [ -r "$ORCA_AGENT_HOOK_ENDPOINT" ]; then',
' . "$ORCA_AGENT_HOOK_ENDPOINT" 2>/dev/null || :',
'fi',
'if [ -z "$ORCA_AGENT_HOOK_PORT" ] || [ -z "$ORCA_AGENT_HOOK_TOKEN" ] || [ -z "$ORCA_PANE_KEY" ]; then',
' exit 0',
'fi',
'payload=$(cat)',
'if [ -z "$payload" ]; then',
' exit 0',
'fi',
// Why: pipe payload to curl's stdin (`payload@-`) instead of an inline
// `payload=$VALUE` arg, so tens-of-KB tool output stays off the curl
// command line (EDR command-line false positives). Wire body is identical.
'printf \'%s\' "$payload" | curl -sS -X POST "http://127.0.0.1:${ORCA_AGENT_HOOK_PORT}/hook/copilot" \\',
' --connect-timeout 0.5 --max-time 1.5 \\',
' -H "Content-Type: application/x-www-form-urlencoded" \\',
' -H "X-Orca-Agent-Hook-Token: ${ORCA_AGENT_HOOK_TOKEN}" \\',
' --data-urlencode "paneKey=${ORCA_PANE_KEY}" \\',
' --data-urlencode "tabId=${ORCA_TAB_ID}" \\',
' --data-urlencode "launchToken=${ORCA_AGENT_LAUNCH_TOKEN}" \\',
' --data-urlencode "worktreeId=${ORCA_WORKTREE_ID}" \\',
' --data-urlencode "hookEventName=${ORCA_COPILOT_HOOK_EVENT}" \\',
' --data-urlencode "env=${ORCA_AGENT_HOOK_ENV}" \\',
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
'exit 0',
''
].join('\n')
}
export class CopilotHookService {
getStatus(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'copilot',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Copilot hooks/orca.json'
}
}
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
const missing: string[] = []
let presentCount = 0
let staleManagedPresent = false
const managedEvents = new Set<string>(COPILOT_EVENTS)
for (const eventName of COPILOT_EVENTS) {
const command = getManagedCommand(scriptPath, eventName)
const definitions = Array.isArray(config.hooks?.[eventName]) ? config.hooks![eventName]! : []
const hasCurrentCommand = definitions.some((definition) =>
definitionHasCurrentCommand(definition, command)
)
if (hasCurrentCommand) {
presentCount += 1
} else {
missing.push(eventName)
}
}
for (const [eventName, definitions] of Object.entries(config.hooks ?? {})) {
if (!Array.isArray(definitions)) {
continue
}
const currentCommand = managedEvents.has(eventName)
? getManagedCommand(scriptPath, eventName)
: null
staleManagedPresent =
staleManagedPresent ||
definitions.some((definition) =>
definitionHasStaleManagedCommand(definition, currentCommand, isManagedCommand)
)
}
const managedHooksPresent = presentCount > 0 || staleManagedPresent
let state: AgentHookInstallState
let detail: string | null
if (config.disableAllHooks === true && managedHooksPresent) {
state = 'partial'
detail = 'Managed Copilot hook file is disabled'
} else if (staleManagedPresent) {
state = 'partial'
detail = 'Managed Copilot hook file contains stale entries'
} else if (missing.length === 0) {
state = 'installed'
detail = null
} else if (presentCount === 0 && !staleManagedPresent) {
state = 'not_installed'
detail = null
} else {
state = 'partial'
detail = `Managed hook missing for events: ${missing.join(', ')}`
}
return { agent: 'copilot', state, configPath, managedHooksPresent, detail }
}
install(): AgentHookInstallStatus {
const configPath = getConfigPath()
const scriptPath = getManagedScriptPath()
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'copilot',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Copilot hooks/orca.json'
}
}
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(COPILOT_EVENTS)
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
for (const eventName of COPILOT_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
nextHooks[eventName] = [
...cleaned,
getManagedHookDefinition(getManagedCommand(scriptPath, eventName))
]
}
config.version = 1
delete config.disableAllHooks
config.hooks = nextHooks
writeManagedScript(scriptPath, getManagedScript())
writeHooksJson(configPath, config)
return this.getStatus()
}
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
const home = remoteHome.replace(/\/$/, '')
const remoteConfigPath = `${home}/.copilot/hooks/orca.json`
const remoteScriptPath = `${home}/.orca/agent-hooks/copilot-hook.sh`
try {
const config = await readHooksJsonRemote(sftp, remoteConfigPath)
if (!config) {
return {
agent: 'copilot',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: 'Could not parse remote Copilot hooks/orca.json'
}
}
const nextHooks = { ...config.hooks }
const managedEvents = new Set<string>(COPILOT_EVENTS)
const isManagedCommand = createManagedCommandMatcher('copilot-hook.sh')
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (managedEvents.has(eventName) || !Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
for (const eventName of COPILOT_EVENTS) {
const current = Array.isArray(nextHooks[eventName]) ? nextHooks[eventName] : []
const cleaned = removeManagedCommands(current, isManagedCommand)
nextHooks[eventName] = [
...cleaned,
getRemoteManagedHookDefinition(
wrapPosixHookCommand(remoteScriptPath, { ORCA_COPILOT_HOOK_EVENT: eventName })
)
]
}
config.version = 1
delete config.disableAllHooks
config.hooks = nextHooks
// Why: SSH remotes use POSIX scripts regardless of Orca's local OS. Write
// the script before hooks/orca.json so a partial install cannot point
// Copilot at a missing managed command.
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript('posix'))
await writeHooksJsonRemote(sftp, remoteConfigPath, config)
return {
agent: 'copilot',
state: 'installed',
configPath: remoteConfigPath,
managedHooksPresent: true,
detail: null
}
} catch (err) {
return {
agent: 'copilot',
state: 'error',
configPath: remoteConfigPath,
managedHooksPresent: false,
detail: err instanceof Error ? err.message : String(err)
}
}
}
remove(): AgentHookInstallStatus {
const configPath = getConfigPath()
if (!existsSync(configPath)) {
return this.getStatus()
}
const config = readHooksJson(configPath)
if (!config) {
return {
agent: 'copilot',
state: 'error',
configPath,
managedHooksPresent: false,
detail: 'Could not parse Copilot hooks/orca.json'
}
}
const nextHooks = { ...config.hooks }
const isManagedCommand = createManagedCommandMatcher(getManagedScriptFileName())
let changed = false
for (const [eventName, definitions] of Object.entries(nextHooks)) {
if (!Array.isArray(definitions)) {
continue
}
const cleaned = removeManagedCommands(definitions, isManagedCommand)
changed = changed || definitionsChanged(definitions, cleaned)
if (cleaned.length === 0) {
delete nextHooks[eventName]
} else {
nextHooks[eventName] = cleaned
}
}
if (!changed) {
return this.getStatus()
}
config.hooks = nextHooks
writeHooksJson(configPath, config)
return this.getStatus()
}
}
export const copilotHookService = new CopilotHookService()