/** * 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 }) }, } }