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
128 changes: 128 additions & 0 deletions integrations/hol-guard/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
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) {
for (const value of [
payload?.classification?.reason,
payload?.reason,
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 minimumAction = nonEmptyString(payload.minimum_action)?.toLowerCase();
const explicitlyBenign = payload?.classification?.explicitly_benign === true;
if (minimumAction === "allow" && explicitlyBenign) {
return { action: "allow" };
}
if (["allow", "monitor", "review", "block"].includes(minimumAction)) {
return { action: "block", reason: guardReason(payload) };
}

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

function guardJsonObject(stdout) {
const output = stdout.trim();
if (!output) return null;
try {
const value = JSON.parse(output);
if (value && typeof value === "object" && !Array.isArray(value)) return value;
} catch {
// Fall back to a final standalone JSON line for wrappers that add diagnostics.
}

const lines = output.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(command) {
return ["command", "test", command, "--json"];
}

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 childEnv = { ...process.env };
const guardHome = nonEmptyString(config.guard_home);
if (guardHome) childEnv.HOL_GUARD_HOME = guardHome;
const home = nonEmptyString(config.home);
if (home) childEnv.HOME = home;

return new Promise((resolve) => {
let stdout = "";
let stderr = "";
let settled = false;
const child = spawn(binary, guardArgs(command), {
stdio: ["ignore", "pipe", "pipe"],
env: childEnv,
cwd: workspace,
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.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;
}
finish(guardResponseToHookResult(guardJsonObject(stdout)));
});
});
}

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
90 changes: 90 additions & 0 deletions test/hol-guard-integration.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
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 command floors to GitAgent hook results", () => {
assert.deepEqual(
guardResponseToHookResult({
minimum_action: "allow",
classification: { explicitly_benign: true },
}),
{ action: "allow" },
);
assert.equal(
guardResponseToHookResult({
minimum_action: "allow",
classification: { explicitly_benign: false },
}).action,
"block",
);
assert.equal(
guardResponseToHookResult({
minimum_action: "monitor",
classification: { explicitly_benign: true },
}).action,
"block",
);
assert.deepEqual(
guardResponseToHookResult({
minimum_action: "review",
classification: { reason: "Guard requires review" },
}),
{ action: "block", reason: "Guard requires review" },
);
assert.equal(guardResponseToHookResult({ minimum_action: "block" }).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 command inspection and blocks a review", 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";\nwriteFileSync(process.env.GUARD_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), guardHome: process.env.HOL_GUARD_HOME, home: process.env.HOME }));\nprocess.stdout.write(JSON.stringify({ minimum_action: "review", classification: { reason: "Guard requires review" } }, null, 2) + "\\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,
guard_home: join(dir, "guard-home"),
home: join(dir, "home"),
},
);
assert.deepEqual(result, { action: "block", reason: "Guard requires review" });

const recorded = JSON.parse(await readFile(capture, "utf-8"));
assert.deepEqual(recorded.argv, ["command", "test", "rm -rf ./build", "--json"]);
assert.equal(recorded.cwd, dir);
assert.equal(recorded.guardHome, join(dir, "guard-home"));
assert.equal(recorded.home, join(dir, "home"));
} finally {
if (previous === undefined) delete process.env.GUARD_CAPTURE;
else process.env.GUARD_CAPTURE = previous;
}
});
});