mirror of
https://github.com/stablyai/orca.git
synced 2026-10-21 00:03:46 +00:00
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>
253 lines
8.5 KiB
TypeScript
253 lines
8.5 KiB
TypeScript
import {
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
readFileSync,
|
|
renameSync,
|
|
unlinkSync,
|
|
writeFileSync
|
|
} from 'node:fs'
|
|
import { homedir } from 'node:os'
|
|
import { dirname, join, posix as pathPosix } from 'node:path'
|
|
import { randomUUID } from 'node:crypto'
|
|
import type { SFTPWrapper } from 'ssh2'
|
|
import type { AgentHookInstallState, AgentHookInstallStatus } from '../../shared/agent-hook-types'
|
|
import {
|
|
createManagedCommandMatcher,
|
|
getSharedManagedScriptPath,
|
|
wrapPosixHookCommand,
|
|
writeManagedScript
|
|
} from '../agent-hooks/installer-utils'
|
|
import {
|
|
readTextFileRemote,
|
|
writeManagedScriptRemote,
|
|
writeTextFileRemoteAtomic
|
|
} from '../agent-hooks/installer-utils-remote'
|
|
import {
|
|
applyManagedKimiHooks,
|
|
KIMI_HOOK_EVENTS,
|
|
readManagedKimiHookEvents,
|
|
removeManagedKimiHooks
|
|
} from './kimi-hook-config-toml'
|
|
|
|
// Why: match the CLI's `KIMI_CODE_HOME ?? ~/.kimi-code` resolution (also used by
|
|
// kimi-fetcher.ts and the AI Vault session scanner) so hooks land in the same
|
|
// home Kimi reads at launch.
|
|
function getKimiHome(): string {
|
|
return process.env.KIMI_CODE_HOME?.trim() || join(homedir(), '.kimi-code')
|
|
}
|
|
|
|
function getConfigPath(): string {
|
|
return join(getKimiHome(), 'config.toml')
|
|
}
|
|
|
|
// Always a POSIX `.sh` script: Kimi runs hook commands through its shell, which
|
|
// is Git Bash even on Windows (see the CLI README / KIMI_SHELL_PATH), so a
|
|
// single curl-based script body works on every platform.
|
|
const MANAGED_SCRIPT_FILE_NAME = 'kimi-hook.sh'
|
|
|
|
function getManagedScriptPath(): string {
|
|
return getSharedManagedScriptPath(MANAGED_SCRIPT_FILE_NAME)
|
|
}
|
|
|
|
function getManagedCommand(scriptPath: string): string {
|
|
// Forward slashes so Kimi's Git Bash shell accepts the path on Windows.
|
|
const posixPath = process.platform === 'win32' ? scriptPath.replaceAll('\\', '/') : scriptPath
|
|
return wrapPosixHookCommand(posixPath)
|
|
}
|
|
|
|
function getManagedScript(): string {
|
|
return [
|
|
'#!/bin/sh',
|
|
// Why: refresh PORT/TOKEN/ENV/VERSION from the current Orca install so a PTY
|
|
// that survived an Orca restart still reaches the live listener. See
|
|
// claude/hook-service.ts for the full rationale.
|
|
'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: worktreeId embeds a filesystem path, so hand-building JSON in POSIX
|
|
// shell is not safe once a path contains quotes or newlines. Post the raw
|
|
// hook payload plus metadata as form fields and let the receiver parse it.
|
|
// 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/kimi" \\',
|
|
' --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 "env=${ORCA_AGENT_HOOK_ENV}" \\',
|
|
' --data-urlencode "version=${ORCA_AGENT_HOOK_VERSION}" \\',
|
|
' --data-urlencode "payload@-" >/dev/null 2>&1 || true',
|
|
'exit 0',
|
|
''
|
|
].join('\n')
|
|
}
|
|
|
|
// Returns the file text, '' when the config does not exist yet (Kimi creates it
|
|
// lazily), or null on an unreadable file so callers can report a structured error.
|
|
function readConfigToml(configPath: string): string | null {
|
|
if (!existsSync(configPath)) {
|
|
return ''
|
|
}
|
|
try {
|
|
return readFileSync(configPath, 'utf-8')
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
// Why: temp+rename keeps a hand-editable config.toml intact if a write is
|
|
// interrupted, and a single rolling .bak makes a bad write recoverable.
|
|
function writeConfigToml(configPath: string, text: string): void {
|
|
const dir = dirname(configPath)
|
|
mkdirSync(dir, { recursive: true })
|
|
if (existsSync(configPath)) {
|
|
try {
|
|
if (readFileSync(configPath, 'utf-8') === text) {
|
|
return
|
|
}
|
|
} catch {
|
|
// Fall through to the atomic write path.
|
|
}
|
|
}
|
|
const tmpPath = join(dir, `.${Date.now()}-${randomUUID()}.tmp`)
|
|
try {
|
|
writeFileSync(tmpPath, text, 'utf-8')
|
|
if (existsSync(configPath)) {
|
|
copyFileSync(configPath, `${configPath}.bak`)
|
|
}
|
|
renameSync(tmpPath, configPath)
|
|
} finally {
|
|
if (existsSync(tmpPath)) {
|
|
try {
|
|
unlinkSync(tmpPath)
|
|
} catch {
|
|
// best effort
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function buildStatus(present: Set<string>, configPath: string): AgentHookInstallStatus {
|
|
const missing = KIMI_HOOK_EVENTS.filter((event) => !present.has(event))
|
|
let state: AgentHookInstallState
|
|
let detail: string | null
|
|
if (missing.length === 0) {
|
|
state = 'installed'
|
|
detail = null
|
|
} else if (present.size === 0) {
|
|
state = 'not_installed'
|
|
detail = null
|
|
} else {
|
|
state = 'partial'
|
|
detail = `Managed hook missing for events: ${missing.join(', ')}`
|
|
}
|
|
return { agent: 'kimi', state, configPath, managedHooksPresent: present.size > 0, detail }
|
|
}
|
|
|
|
export class KimiHookService {
|
|
getStatus(): AgentHookInstallStatus {
|
|
const configPath = getConfigPath()
|
|
const text = readConfigToml(configPath)
|
|
if (text === null) {
|
|
return {
|
|
agent: 'kimi',
|
|
state: 'error',
|
|
configPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not read Kimi config.toml'
|
|
}
|
|
}
|
|
const isManagedCommand = createManagedCommandMatcher(MANAGED_SCRIPT_FILE_NAME)
|
|
return buildStatus(readManagedKimiHookEvents(text, isManagedCommand), configPath)
|
|
}
|
|
|
|
install(): AgentHookInstallStatus {
|
|
const configPath = getConfigPath()
|
|
const text = readConfigToml(configPath)
|
|
if (text === null) {
|
|
return {
|
|
agent: 'kimi',
|
|
state: 'error',
|
|
configPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not read Kimi config.toml'
|
|
}
|
|
}
|
|
const scriptPath = getManagedScriptPath()
|
|
const command = getManagedCommand(scriptPath)
|
|
// Write the script first so config.toml never points at a missing script.
|
|
writeManagedScript(scriptPath, getManagedScript())
|
|
writeConfigToml(configPath, applyManagedKimiHooks(text, command))
|
|
return this.getStatus()
|
|
}
|
|
|
|
// Why: install Orca's managed Kimi hooks on a remote box over SFTP, mirroring
|
|
// the local install. POSIX-only by design (Kimi's shell is sh/Git Bash); the
|
|
// managed script body is already platform-independent.
|
|
async installRemote(sftp: SFTPWrapper, remoteHome: string): Promise<AgentHookInstallStatus> {
|
|
const remoteConfigPath = pathPosix.join(remoteHome, '.kimi-code', 'config.toml')
|
|
const remoteScriptPath = pathPosix.join(
|
|
remoteHome,
|
|
'.orca',
|
|
'agent-hooks',
|
|
MANAGED_SCRIPT_FILE_NAME
|
|
)
|
|
try {
|
|
// null (file absent) → start from an empty config; Kimi creates it lazily.
|
|
const text = (await readTextFileRemote(sftp, remoteConfigPath)) ?? ''
|
|
const command = wrapPosixHookCommand(remoteScriptPath)
|
|
// Write the script first so config.toml never points at a missing script.
|
|
await writeManagedScriptRemote(sftp, remoteScriptPath, getManagedScript())
|
|
await writeTextFileRemoteAtomic(sftp, remoteConfigPath, applyManagedKimiHooks(text, command))
|
|
return {
|
|
agent: 'kimi',
|
|
state: 'installed',
|
|
configPath: remoteConfigPath,
|
|
managedHooksPresent: true,
|
|
detail: null
|
|
}
|
|
} catch (err) {
|
|
return {
|
|
agent: 'kimi',
|
|
state: 'error',
|
|
configPath: remoteConfigPath,
|
|
managedHooksPresent: false,
|
|
detail: err instanceof Error ? err.message : String(err)
|
|
}
|
|
}
|
|
}
|
|
|
|
remove(): AgentHookInstallStatus {
|
|
const configPath = getConfigPath()
|
|
const text = readConfigToml(configPath)
|
|
if (text === null) {
|
|
return {
|
|
agent: 'kimi',
|
|
state: 'error',
|
|
configPath,
|
|
managedHooksPresent: false,
|
|
detail: 'Could not read Kimi config.toml'
|
|
}
|
|
}
|
|
const { text: nextText, changed } = removeManagedKimiHooks(text)
|
|
if (changed) {
|
|
writeConfigToml(configPath, nextText)
|
|
}
|
|
return this.getStatus()
|
|
}
|
|
}
|
|
|
|
export const kimiHookService = new KimiHookService()
|