Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 103 additions & 0 deletions docs/hol-guard-before-tool.md
Original file line number Diff line number Diff line change
@@ -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.
200 changes: 200 additions & 0 deletions scripts/hol-guard-before-tool.mjs
Original file line number Diff line number Diff line change
@@ -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();
}
81 changes: 81 additions & 0 deletions scripts/hol-guard-before-tool.test.mjs
Original file line number Diff line number Diff line change
@@ -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);
}
});