diff --git a/integrations/hol-guard/README.md b/integrations/hol-guard/README.md new file mode 100644 index 0000000..eda2d08 --- /dev/null +++ b/integrations/hol-guard/README.md @@ -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`. diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs new file mode 100644 index 0000000..b3216bc --- /dev/null +++ b/integrations/hol-guard/index.mjs @@ -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)); +} 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..0dd3235 --- /dev/null +++ b/test/hol-guard-integration.test.ts @@ -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; + } + }); +});