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
48 changes: 48 additions & 0 deletions integrations/hol-guard/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# HOL Guard integration

This is a native `pre_tool_use` plugin for GitAgent's `cli` tool. It sends each shell command to HOL Guard before GitAgent executes it and blocks the tool call when Guard denies it, requires review, fails, times out, or returns an unrecognized decision.

The integration is intentionally narrow: it protects GitAgent shell execution and does not claim that unrelated tools are automatically covered.

## Requirements

Install HOL Guard in an isolated CLI environment:

```bash
pipx install hol-guard
```

The plugin invokes the installed `hol-guard` executable directly. No replacement policy engine is implemented in GitAgent.

## Install from a GitAgent checkout

Copy this directory into the agent's local plugin directory:

```bash
mkdir -p /path/to/agent/plugins/hol-guard
cp -R integrations/hol-guard/. /path/to/agent/plugins/hol-guard/
```

Then enable it in `agent.yaml`:

```yaml
plugins:
hol-guard:
enabled: true
config:
binary: hol-guard
workspace: /path/to/agent
```

`HOL_GUARD_BIN` and `HOL_GUARD_HOME` can also provide the executable and Guard state directory.

## Decision mapping

The plugin calls HOL Guard's hook runtime using a `PreToolUse` payload for the GitAgent `cli` command. Guard remains the policy authority.

- Guard `allow` -> GitAgent allows the command.
- Guard `deny`/`block` -> GitAgent blocks the command.
- Guard `ask`/`review` -> GitAgent blocks the command until the review is resolved outside the tool call.
- Guard timeout, launch failure, malformed output, or unknown decision -> GitAgent blocks the command (fail closed).

GitAgent's hook contract supports `allow`, `block`, and `modify`, but it has no native pending-review state, so Guard review decisions are conservatively mapped to `block`.
151 changes: 151 additions & 0 deletions integrations/hol-guard/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
import { spawn } from "node:child_process";

const DEFAULT_TIMEOUT_MS = 6000;

function nonEmptyString(value) {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}

function guardReason(payload) {
const hookSpecific = payload?.hookSpecificOutput;
for (const value of [
hookSpecific?.permissionDecisionReason,
payload?.reason,
payload?.stopReason,
payload?.message,
]) {
const text = nonEmptyString(value);
if (text) return text;
}
return "HOL Guard did not allow this command.";
}

export function guardResponseToHookResult(payload) {
if (!payload || typeof payload !== "object") {
return { action: "block", reason: "HOL Guard returned an invalid response." };
}

const permissionDecision = payload.hookSpecificOutput?.permissionDecision;
if (permissionDecision === "allow") return { action: "allow" };
if (permissionDecision === "deny") {
return { action: "block", reason: guardReason(payload) };
}
if (permissionDecision === "ask") {
return {
action: "block",
reason: guardReason(payload) || "HOL Guard requires review before this command can run.",
};
}

const decision = nonEmptyString(payload.decision)?.toLowerCase();
if (decision === "allow") return { action: "allow" };
if (decision === "block" || decision === "deny" || decision === "ask" || decision === "review") {
return { action: "block", reason: guardReason(payload) };
}

const policyAction = nonEmptyString(payload.policy_action)?.toLowerCase();
if (policyAction === "allow" || policyAction === "warn") return { action: "allow" };
if (["block", "review", "require-reapproval", "sandbox-required"].includes(policyAction)) {
return { action: "block", reason: guardReason(payload) };
}

return { action: "block", reason: "HOL Guard returned no recognized decision." };
}

function lastJsonObject(stdout) {
const lines = stdout.split(/\r?\n/).map((line) => line.trim()).filter(Boolean);
for (let i = lines.length - 1; i >= 0; i--) {
try {
const value = JSON.parse(lines[i]);
if (value && typeof value === "object" && !Array.isArray(value)) return value;
} catch {
// Continue scanning in case HOL Guard emitted a diagnostic line first.
}
}
return null;
}

function guardArgs(config) {
const args = ["guard", "hook"];
const guardHome = nonEmptyString(config.guard_home);
const home = nonEmptyString(config.home);
const workspace = nonEmptyString(config.workspace);
if (guardHome) args.push("--guard-home", guardHome);
args.push("--harness", "codex");
if (home) args.push("--home", home);
if (workspace) args.push("--workspace", workspace);
args.push("--json");
return args;
}

export async function evaluateWithGuard(ctx, config = {}) {
if (ctx?.tool !== "cli") return { action: "allow" };
const command = nonEmptyString(ctx?.args?.command);
if (!command) return { action: "allow" };

const binary = nonEmptyString(config.binary) || process.env.HOL_GUARD_BIN || "hol-guard";
const configuredTimeout = Number(config.timeout_ms);
const timeoutMs = Number.isFinite(configuredTimeout) && configuredTimeout > 0
? configuredTimeout
: DEFAULT_TIMEOUT_MS;
const workspace = nonEmptyString(config.workspace) || process.cwd();
const input = JSON.stringify({
hook_event_name: "PreToolUse",
event: "PreToolUse",
session_id: ctx.session_id,
tool_name: "Bash",
tool_input: { command },
cwd: workspace,
});

return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn(binary, guardArgs({ ...config, workspace }), {
stdio: ["pipe", "pipe", "pipe"],
env: { ...process.env },
shell: false,
});

const finish = (result) => {
if (settled) return;
settled = true;
clearTimeout(timer);
resolve(result);
};

const timer = setTimeout(() => {
child.kill("SIGTERM");
finish({
action: "block",
reason: `HOL Guard did not return a decision within ${timeoutMs}ms.`,
});
}, timeoutMs);

child.stdout.on("data", (chunk) => { stdout += chunk.toString("utf-8"); });
child.stderr.on("data", (chunk) => { stderr += chunk.toString("utf-8"); });
child.stdin.on("error", () => {});
child.on("error", (error) => {
finish({ action: "block", reason: `HOL Guard could not start: ${error.message}` });
});
child.on("close", (code) => {
if (settled) return;
if (code !== 0) {
finish({
action: "block",
reason: nonEmptyString(stderr) || `HOL Guard exited with code ${code}.`,
});
return;
}
const payload = lastJsonObject(stdout);
finish(guardResponseToHookResult(payload));
});

child.stdin.end(input);
});
}

export async function register(api) {
api.registerHook("pre_tool_use", async (ctx) => evaluateWithGuard(ctx, api.config));
}
30 changes: 30 additions & 0 deletions integrations/hol-guard/plugin.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
id: hol-guard
name: HOL Guard
version: 0.1.0
description: Gate GitAgent CLI tool calls through HOL Guard before execution
author: Hashgraph Online
license: MIT
engine: ">=2.2.0"
entry: index.mjs

config:
properties:
binary:
type: string
description: HOL Guard executable to invoke
env: HOL_GUARD_BIN
default: hol-guard
guard_home:
type: string
description: Optional HOL Guard state directory
env: HOL_GUARD_HOME
home:
type: string
description: Optional home directory passed to HOL Guard
workspace:
type: string
description: Workspace path evaluated by HOL Guard
timeout_ms:
type: number
description: Maximum time to wait for a Guard decision
default: 6000
73 changes: 73 additions & 0 deletions test/hol-guard-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
import { chmod, mkdtemp, readFile, writeFile } from "fs/promises";
import { tmpdir } from "os";
import { join } from "path";
import { describe, it } from "node:test";
import assert from "node:assert/strict";
import { evaluateWithGuard, guardResponseToHookResult } from "../integrations/hol-guard/index.mjs";

describe("HOL Guard GitAgent integration", () => {
it("maps Guard decisions to GitAgent hook results", () => {
assert.deepEqual(
guardResponseToHookResult({ hookSpecificOutput: { permissionDecision: "allow" } }),
{ action: "allow" },
);
assert.deepEqual(
guardResponseToHookResult({
hookSpecificOutput: {
permissionDecision: "deny",
permissionDecisionReason: "blocked by guard",
},
}),
{ action: "block", reason: "blocked by guard" },
);
assert.equal(
guardResponseToHookResult({ hookSpecificOutput: { permissionDecision: "ask" } }).action,
"block",
);
assert.equal(guardResponseToHookResult({ unexpected: true }).action, "block");
});

it("only gates the cli tool", async () => {
assert.deepEqual(
await evaluateWithGuard({ tool: "read", args: { path: "README.md" } }, { binary: "missing-guard" }),
{ action: "allow" },
);
});

it("invokes HOL Guard with the command payload and blocks a deny", async (t) => {
if (process.platform === "win32") {
t.skip("fixture executable uses a POSIX shebang");
return;
}

const dir = await mkdtemp(join(tmpdir(), "gitagent-hol-guard-"));
const capture = join(dir, "capture.json");
const fixture = join(dir, "hol-guard-fixture.mjs");
await writeFile(
fixture,
`#!/usr/bin/env node\nimport { writeFileSync } from "node:fs";\nlet input = "";\nfor await (const chunk of process.stdin) input += chunk;\nwriteFileSync(process.env.GUARD_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), input: JSON.parse(input) }));\nprocess.stdout.write(JSON.stringify({ hookSpecificOutput: { permissionDecision: "deny", permissionDecisionReason: "Guard blocked the command" } }) + "\\n");\n`,
"utf-8",
);
await chmod(fixture, 0o755);

const previous = process.env.GUARD_CAPTURE;
process.env.GUARD_CAPTURE = capture;
try {
const result = await evaluateWithGuard(
{ session_id: "session-1", tool: "cli", args: { command: "rm -rf ./build" } },
{ binary: fixture, workspace: dir, timeout_ms: 2000 },
);
assert.deepEqual(result, { action: "block", reason: "Guard blocked the command" });

const recorded = JSON.parse(await readFile(capture, "utf-8"));
assert.deepEqual(recorded.argv.slice(0, 4), ["guard", "hook", "--harness", "codex"]);
assert.ok(recorded.argv.includes("--json"));
assert.equal(recorded.input.hook_event_name, "PreToolUse");
assert.equal(recorded.input.tool_name, "Bash");
assert.equal(recorded.input.tool_input.command, "rm -rf ./build");
} finally {
if (previous === undefined) delete process.env.GUARD_CAPTURE;
else process.env.GUARD_CAPTURE = previous;
}
});
});