109 lines
3.5 KiB
JavaScript

/**
* Thin adapter from DeepSeek Harness tool calls to the claude-harness bash hooks.
*
* The guard logic stays in `hooks/harness_target.py` (the source of truth, also
* covered by the repository's own tests). This module only:
* - maps DSH tool names to the Claude hook vocabulary;
* - synthesizes the `{ cwd, tool_name, tool_input }` payload;
* - runs `guard-main.sh` / `require-plan.sh`;
* - turns `exit 2` into a denial reason and every other failure into a pass,
* preserving the harness invariant "a parsing failure must not block".
*/
import { execFileSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
/** DSH tool name -> Claude Code tool name used by the hooks. */
const TOOL_NAMES = {
write: 'Write',
edit: 'Edit',
multi_edit: 'MultiEdit',
str_replace_editor: 'Edit',
bash: 'Bash',
}
/** Tools that `require-plan.sh` must inspect in addition to `guard-main.sh`. */
const PLAN_TOOLS = new Set(['write', 'edit', 'multi_edit', 'str_replace_editor'])
const DEFAULT_TIMEOUT_MS = 15_000
/**
* Run one hook script with the synthesized payload.
* @returns the denial text for exit 2, otherwise undefined.
*/
export function runHook({ script, hooksDir, payload, logger, timeoutMs = DEFAULT_TIMEOUT_MS }) {
const path = join(hooksDir, script)
if (!existsSync(path)) {
logger?.warn?.('[dsh-harness] hook not found: %s', path)
return undefined
}
try {
execFileSync('bash', [path], {
input: JSON.stringify(payload),
encoding: 'utf8',
stdio: ['pipe', 'pipe', 'pipe'],
timeout: timeoutMs,
})
return undefined
} catch (error) {
if (error?.status === 2) {
const reason = String(error.stderr ?? '').trim()
return reason || `BLOCKED by ${script}`
}
// Fail open: a broken hook must never stop the agent.
logger?.warn?.('[dsh-harness] %s failed open: %s', script, error?.message ?? String(error))
return undefined
}
}
/** Normalize the DSH arguments into the shape `harness_target.py` reads. */
export function normalizeToolInput(toolName, args) {
const input = { ...(args ?? {}) }
if (toolName !== 'Bash') {
const path = input.file_path ?? input.path
if (typeof path === 'string' && path !== '') input.file_path = path
}
return input
}
/**
* Decide one tool call.
* @param {object} options
* @param {{name: string, arguments?: object}} options.exec - DSH tool execution.
* @param {string} options.cwd - Session working directory.
* @param {string} options.hooksDir - Directory holding guard-main.sh.
* @param {object} [options.logger] - Cordis logger.
* @returns {string|undefined} denial reason.
*/
export function checkTool({ exec, cwd, hooksDir, logger }) {
if (!cwd || !exec || typeof exec.name !== 'string') return undefined
const toolName = TOOL_NAMES[exec.name]
if (!toolName) return undefined
const payload = {
cwd,
tool_name: toolName,
tool_input: normalizeToolInput(toolName, exec.arguments),
}
const guardReason = runHook({ script: 'guard-main.sh', hooksDir, payload, logger })
if (guardReason !== undefined) return guardReason
if (PLAN_TOOLS.has(exec.name)) {
const planReason = runHook({ script: 'require-plan.sh', hooksDir, payload, logger })
if (planReason !== undefined) return planReason
}
return undefined
}
/** Bind the module to one bundle configuration. */
export function createGuard({ hooksDir, logger }) {
return {
check(exec, cwd) {
return checkTool({ exec, cwd, hooksDir, logger })
},
}
}