diff --git a/docs/hol-guard-before-tool.md b/docs/hol-guard-before-tool.md new file mode 100644 index 0000000..ffcbd0e --- /dev/null +++ b/docs/hol-guard-before-tool.md @@ -0,0 +1,103 @@ +# HOL Guard `before_tool` adapter packet + +Status: owned-fork implementation and tests prepared. This is not an upstream integration or a release artifact. + +## Why this maps cleanly + +Klaat Code sends a JSON object to `before_tool` hooks. For a `run_command` call, the relevant fields are: + +- `event`: `before_tool` +- `project_root`: current project root +- `tool_name`: `run_command` +- `tool_args`: the raw JSON argument string passed to the tool + +Klaat Code blocks a tool call when the hook exits with status `2` or writes a JSON object such as: + +```json +{"decision":"block","reason":"..."} +``` + +HOL Guard already has native pre-tool authority for command decisions. The supported native request is a versioned `PreToolUse` envelope whose `tool_input.command` contains the command string. The Rust runtime owns the security decision. The adapter transports and renders that result rather than reimplementing Guard policy in Klaat Code. + +## Prepared adapter behavior + +The owned fork contains `scripts/hol-guard-before-tool.mjs`. For `tool_name == "run_command"` it: + +1. Parses `tool_args` as JSON. +2. Extracts the non-empty `command` string. Missing or malformed command input fails closed. +3. Constructs a Guard `PreToolUse` request with the current project as `cwd`. +4. Sends the complete request through `hol-guard hook --harness codex --json`, with the workspace when available, so Guard's native hook authority remains the decision owner. +5. Allows an authoritative native `allow` decision, or the explicit `native_policy_warning` allow-with-warning result. +6. Fails closed when native review is unavailable or not ready, including `native_pre_tool_unavailable`, `native_policy_not_ready`, worker/daemon availability failures, unmanaged-harness passthrough, unknown warning results, malformed output, timeout, missing Guard, review, reapproval, sandbox-required, or block. + +The adapter does not use `hol-guard command explain` as enforcement authority. + +## Validation completed + +The exact owned branch was downloaded into an independent Linux sandbox and validated with Node 20 and Bun 1.4.2. + +The following checks pass: + +- `node --check scripts/hol-guard-before-tool.mjs` +- `node --test scripts/hol-guard-before-tool.test.mjs`, 7/7 focused tests +- `bun install --frozen-lockfile` +- `bun run typecheck` +- `bun run build` + +The focused tests cover command extraction, unrelated hook pass-through, malformed `tool_args`, complete `PreToolUse` envelope construction, authoritative allow and policy-warning handling, unavailable native review, unknown warnings, review/block responses, and malformed/missing-reason responses. + +## Real HOL Guard 3.4.2 availability test + +A clean isolated Python environment installed the published `hol-guard==3.4.2` package successfully. + +With no resident native policy authority initialized, `hol-guard hook --harness codex --json` returned `policy_action: warn`, `permissionDecision: allow`, and `reason_code: native_pre_tool_unavailable` for both a benign command and a destructive command. That response means Guard could not complete the native decision. It is not execution proof. + +The first adapter version treated a generic `warn` plus `permissionDecision: allow` as sufficient and therefore would have allowed that unavailable state. The owned-fork adapter now rejects Guard availability and not-ready reason codes and rejects unknown warning results. Re-running the adapter against the same real 3.4.2 runtime now emits Klaat's block result for both test commands while Guard authority is unavailable. + +This closes the unavailable-runtime fail-open defect. It does not yet prove the final authoritative allow/deny path. + +## Remaining real-runtime gate + +The implementation is not ready for upstream submission until an isolated managed Guard runtime can produce authoritative native decisions for these cases: + +| Case | Expected result | +| --- | --- | +| `pwd` | Klaat Code command proceeds after authoritative Guard allow | +| `git status --short` | Klaat Code command proceeds after authoritative Guard allow | +| `rm -rf /` | Klaat Code command is blocked | +| `shred ~/.ssh/id_ed25519` | Klaat Code command is blocked | +| malformed `tool_args` | fail closed | +| Guard runtime unavailable | fail closed | + +The deny-path test must prove that the target command is not executed. + +An isolated `hol-guard install codex` attempt in the validation sandbox did not establish that authority because Guard rejected the sandbox interpreter as writable by another user. No real user installation was modified. A clean authoritative setup remains required before upstream submission. + +## Candidate hook configuration + +The adapter should remain project-local until there is a stable shipped integration surface: + +```json +{ + "before_tool": [ + { + "matcher": "run_command", + "command": "node ./scripts/hol-guard-before-tool.mjs" + } + ] +} +``` + +The current upstream README example points at `./scripts/guard-shell.sh`, but that helper is not present in the repository. This owned-fork candidate supplies the missing executable behavior without changing Klaat's hook semantics. + +## Upstream gate + +Before any upstream PR: + +- revalidate the current Klaat Code upstream head, hook payload, block semantics, contribution guidance, and queue ownership; +- revalidate HOL Guard's current native hook entry point; +- keep Klaat Code typecheck/build plus focused adapter tests green; +- prove one authoritative allow path and one authoritative deny path with the real Guard runtime, including target command non-execution on deny; +- confirm the contribution still fits current Distribution WIP and maintainer-conversion gates. + +Until those gates pass, this remains owned-fork preparation and carries no strict outcome value. diff --git a/scripts/hol-guard-before-tool.mjs b/scripts/hol-guard-before-tool.mjs new file mode 100644 index 0000000..956c626 --- /dev/null +++ b/scripts/hol-guard-before-tool.mjs @@ -0,0 +1,200 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { pathToFileURL } from 'node:url'; +import process from 'node:process'; + +const MAX_STDIN_BYTES = 1_000_000; +const GUARD_TIMEOUT_MS = 9_000; + +const NON_AUTHORITATIVE_REASON_CODES = new Set([ + 'native_hook_disabled', + 'native_shadow_diagnostic_disabled', + 'native_policy_not_ready', + 'native_hook_event_unavailable', + 'native_pre_tool_unavailable', + 'native_post_tool_unavailable', + 'native_overloaded', + 'native_hook_worker_unavailable', + 'native_hook_worker_unavailable_before_compatibility', + 'native_hook_worker_unsupported', + 'native_hook_worker_exception', + 'native_hook_compatibility_disabled', + 'native_hook_edge_invalid_response', + 'native_hook_edge_unavailable', + 'python_hook_oracle_unavailable', + 'python_oracle_exception', + 'watch_recording_only', + 'daemon_hook_queue_capacity', + 'daemon_hook_deadline_exhausted', + 'daemon_hook_process_deadline_exhausted', + 'daemon_hook_process_not_ready', + 'daemon_hook_process_failed', + 'daemon_hook_process_invalid_request', + 'daemon_hook_process_guard_home_mismatch', + 'daemon_worker_exception', + 'harness_not_managed', + 'native_degraded_emergency_safe', +]); + +function block(reason) { + const message = typeof reason === 'string' && reason.trim() + ? reason.trim() + : 'HOL Guard did not return an authoritative allow decision.'; + process.stdout.write(`${JSON.stringify({ decision: 'block', reason: message })}\n`); +} + +export function extractKlaatCommand(payload) { + if (!payload || typeof payload !== 'object') { + throw new Error('invalid Klaat hook payload'); + } + if (payload.event !== 'before_tool' || payload.tool_name !== 'run_command') { + return null; + } + if (typeof payload.tool_args !== 'string') { + throw new Error('run_command tool_args must be a JSON string'); + } + let args; + try { + args = JSON.parse(payload.tool_args); + } catch { + throw new Error('run_command tool_args is not valid JSON'); + } + if (!args || typeof args !== 'object' || typeof args.command !== 'string' || !args.command.trim()) { + throw new Error('run_command command is missing'); + } + return args.command; +} + +export function buildGuardPayload(payload, command) { + const guardPayload = { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command }, + }; + if (typeof payload.session_id === 'string' && payload.session_id) { + guardPayload.session_id = payload.session_id; + } + if (typeof payload.project_root === 'string' && payload.project_root) { + guardPayload.cwd = payload.project_root; + } + return guardPayload; +} + +function parseGuardJson(stdout) { + const text = typeof stdout === 'string' ? stdout.trim() : ''; + if (!text) return null; + try { + return JSON.parse(text); + } catch { + for (const line of text.split(/\r?\n/).reverse()) { + const candidate = line.trim(); + if (!candidate) continue; + try { + const parsed = JSON.parse(candidate); + if (parsed && typeof parsed === 'object') return parsed; + } catch { + // Keep looking for the final JSON response line. + } + } + return null; + } +} + +export function decisionFromGuardResponse(response) { + if (!response || typeof response !== 'object') { + return { allow: false, reason: 'HOL Guard returned an invalid response.' }; + } + const hookSpecific = response.hookSpecificOutput; + const permissionDecision = hookSpecific && typeof hookSpecific === 'object' + ? hookSpecific.permissionDecision + : undefined; + const policyAction = response.policy_action; + const reasonCode = typeof response.reason_code === 'string' + ? response.reason_code.trim() + : ''; + const reason = + (typeof response.reason === 'string' && response.reason) || + (hookSpecific && typeof hookSpecific === 'object' && + typeof hookSpecific.permissionDecisionReason === 'string' && + hookSpecific.permissionDecisionReason) || + (reasonCode && `HOL Guard blocked this command (${reasonCode}).`) || + 'HOL Guard did not return an authoritative allow decision.'; + + if (permissionDecision !== 'allow' || !reasonCode) { + return { allow: false, reason }; + } + + if (NON_AUTHORITATIVE_REASON_CODES.has(reasonCode)) { + return { allow: false, reason }; + } + + if (policyAction === 'allow') { + return { allow: true, reason: '' }; + } + + if (policyAction === 'warn' && reasonCode === 'native_policy_warning') { + return { allow: true, reason: '' }; + } + + return { allow: false, reason }; +} + +async function readStdin() { + const chunks = []; + let size = 0; + for await (const chunk of process.stdin) { + size += chunk.length; + if (size > MAX_STDIN_BYTES) throw new Error('Klaat hook payload exceeds the size limit'); + chunks.push(chunk); + } + return Buffer.concat(chunks).toString('utf8'); +} + +export async function main() { + let payload; + try { + const input = await readStdin(); + payload = JSON.parse(input); + } catch (error) { + block(error instanceof Error ? error.message : 'invalid Klaat hook payload'); + return; + } + + let command; + try { + command = extractKlaatCommand(payload); + } catch (error) { + block(error instanceof Error ? error.message : 'invalid run_command payload'); + return; + } + if (command === null) return; + + const guardPayload = buildGuardPayload(payload, command); + const args = ['hook', '--harness', 'codex']; + if (typeof payload.project_root === 'string' && payload.project_root) { + args.push('--workspace', payload.project_root); + } + args.push('--json'); + + const result = spawnSync('hol-guard', args, { + input: `${JSON.stringify(guardPayload)}\n`, + encoding: 'utf8', + timeout: GUARD_TIMEOUT_MS, + maxBuffer: MAX_STDIN_BYTES, + windowsHide: true, + }); + + if (result.error || result.status !== 0) { + block('HOL Guard could not complete an authoritative command review.'); + return; + } + + const response = parseGuardJson(result.stdout); + const decision = decisionFromGuardResponse(response); + if (!decision.allow) block(decision.reason); +} + +const invokedPath = process.argv[1] ? pathToFileURL(process.argv[1]).href : ''; +if (import.meta.url === invokedPath) { + await main(); +} diff --git a/scripts/hol-guard-before-tool.test.mjs b/scripts/hol-guard-before-tool.test.mjs new file mode 100644 index 0000000..ed107b3 --- /dev/null +++ b/scripts/hol-guard-before-tool.test.mjs @@ -0,0 +1,81 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + buildGuardPayload, + decisionFromGuardResponse, + extractKlaatCommand, +} from './hol-guard-before-tool.mjs'; + +test('extracts run_command command from Klaat tool_args', () => { + assert.equal(extractKlaatCommand({ + event: 'before_tool', + tool_name: 'run_command', + tool_args: JSON.stringify({ command: 'git status --short' }), + }), 'git status --short'); +}); + +test('ignores unrelated hook events defensively', () => { + assert.equal(extractKlaatCommand({ event: 'after_tool', tool_name: 'run_command', tool_args: '{}' }), null); +}); + +test('fails closed on malformed run_command input', () => { + assert.throws(() => extractKlaatCommand({ + event: 'before_tool', + tool_name: 'run_command', + tool_args: '{', + })); +}); + +test('builds complete PreToolUse command envelope', () => { + assert.deepEqual( + buildGuardPayload({ session_id: 's1', project_root: '/repo' }, 'pwd'), + { + hook_event_name: 'PreToolUse', + tool_name: 'Bash', + tool_input: { command: 'pwd' }, + session_id: 's1', + cwd: '/repo', + }, + ); +}); + +test('allows authoritative native allow and policy warning decisions', () => { + assert.equal(decisionFromGuardResponse({ + policy_action: 'allow', + reason_code: 'native_exact_safe_command', + hookSpecificOutput: { permissionDecision: 'allow' }, + }).allow, true); + assert.equal(decisionFromGuardResponse({ + policy_action: 'warn', + reason_code: 'native_policy_warning', + hookSpecificOutput: { permissionDecision: 'allow' }, + }).allow, true); +}); + +test('fails closed on unavailable and non-authoritative warn decisions', () => { + for (const reason_code of [ + 'native_pre_tool_unavailable', + 'native_policy_not_ready', + 'native_hook_worker_unavailable', + 'harness_not_managed', + 'future_unknown_warning', + ]) { + assert.equal(decisionFromGuardResponse({ + policy_action: 'warn', + reason_code, + reason: 'Guard did not complete an authoritative review.', + hookSpecificOutput: { permissionDecision: 'allow' }, + }).allow, false, reason_code); + } +}); + +test('blocks review, block, malformed, and missing-reason responses', () => { + for (const response of [ + { policy_action: 'review', reason_code: 'native_command_review_required', hookSpecificOutput: { permissionDecision: 'deny' } }, + { policy_action: 'block', reason_code: 'native_destructive_command', hookSpecificOutput: { permissionDecision: 'deny' } }, + { policy_action: 'allow', hookSpecificOutput: { permissionDecision: 'allow' } }, + {}, + ]) { + assert.equal(decisionFromGuardResponse(response).allow, false); + } +});