diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs new file mode 100644 index 0000000..4be3ca5 --- /dev/null +++ b/integrations/hol-guard/index.mjs @@ -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)); +} diff --git a/integrations/hol-guard/plugin.yaml b/integrations/hol-guard/plugin.yaml new file mode 100644 index 0000000..f820e66 --- /dev/null +++ b/integrations/hol-guard/plugin.yaml @@ -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 diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts new file mode 100644 index 0000000..6e87343 --- /dev/null +++ b/test/hol-guard-integration.test.ts @@ -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; + } + }); +});