From 65f868355b84ed10315177dcde904966ce6145bc Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Thu, 3 Sep 2026 09:40:11 -0400 Subject: [PATCH 01/13] Add HOL Guard pre-tool plugin integration --- integrations/hol-guard/README.md | 48 +++++++++ integrations/hol-guard/index.mjs | 151 +++++++++++++++++++++++++++++ integrations/hol-guard/plugin.yaml | 30 ++++++ test/hol-guard-integration.test.ts | 73 ++++++++++++++ 4 files changed, 302 insertions(+) create mode 100644 integrations/hol-guard/README.md create mode 100644 integrations/hol-guard/index.mjs create mode 100644 integrations/hol-guard/plugin.yaml create mode 100644 test/hol-guard-integration.test.ts 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; + } + }); +}); From 2f667f1b44b31dd774e90626d53388bf900138e2 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:37:25 -0400 Subject: [PATCH 02/13] fix: attribute Guard hook to GitAgent --- integrations/hol-guard/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index b3216bc..8f21aa0 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -71,7 +71,7 @@ function guardArgs(config) { const home = nonEmptyString(config.home); const workspace = nonEmptyString(config.workspace); if (guardHome) args.push("--guard-home", guardHome); - args.push("--harness", "codex"); + args.push("--harness", "gitagent"); if (home) args.push("--home", home); if (workspace) args.push("--workspace", workspace); args.push("--json"); From 3d207b397dee404623bec5320933a08110633252 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sat, 12 Sep 2026 23:37:35 -0400 Subject: [PATCH 03/13] test: verify GitAgent Guard attribution --- test/hol-guard-integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index 0dd3235..4517307 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -60,7 +60,7 @@ describe("HOL Guard GitAgent integration", () => { 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.deepEqual(recorded.argv.slice(0, 4), ["guard", "hook", "--harness", "gitagent"]); assert.ok(recorded.argv.includes("--json")); assert.equal(recorded.input.hook_event_name, "PreToolUse"); assert.equal(recorded.input.tool_name, "Bash"); From bdf8f3478fcefb83562970d039a6ec80bdadd53a Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:42:22 -0400 Subject: [PATCH 04/13] Fix HOL Guard hook CLI invocation --- integrations/hol-guard/index.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index 8f21aa0..f8fc00a 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -66,7 +66,7 @@ function lastJsonObject(stdout) { } function guardArgs(config) { - const args = ["guard", "hook"]; + const args = ["hook"]; const guardHome = nonEmptyString(config.guard_home); const home = nonEmptyString(config.home); const workspace = nonEmptyString(config.workspace); From f263b670a142e3d45d4538b7cbf8a7ef283bb992 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 05:42:32 -0400 Subject: [PATCH 05/13] Fix HOL Guard hook CLI test --- test/hol-guard-integration.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index 4517307..f9e1242 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -60,7 +60,7 @@ describe("HOL Guard GitAgent integration", () => { 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", "gitagent"]); + assert.deepEqual(recorded.argv.slice(0, 3), ["hook", "--harness", "gitagent"]); assert.ok(recorded.argv.includes("--json")); assert.equal(recorded.input.hook_event_name, "PreToolUse"); assert.equal(recorded.input.tool_name, "Bash"); From de2784aa0031146a17d2cea2f499d1908108fee9 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:15:05 -0400 Subject: [PATCH 06/13] HOL_GUARD_GITAGENT_COMMAND_GATE --- integrations/hol-guard/index.mjs | 60 ++++++-------------------------- 1 file changed, 11 insertions(+), 49 deletions(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index f8fc00a..acb6df1 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -7,11 +7,9 @@ function nonEmptyString(value) { } function guardReason(payload) { - const hookSpecific = payload?.hookSpecificOutput; for (const value of [ - hookSpecific?.permissionDecisionReason, + payload?.classification?.reason, payload?.reason, - payload?.stopReason, payload?.message, ]) { const text = nonEmptyString(value); @@ -25,27 +23,11 @@ export function guardResponseToHookResult(payload) { 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 minimumAction = nonEmptyString(payload.minimum_action)?.toLowerCase(); + if (minimumAction === "allow" || minimumAction === "monitor") { + return { action: "allow" }; } - - 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)) { + if (minimumAction === "review" || minimumAction === "block") { return { action: "block", reason: guardReason(payload) }; } @@ -65,17 +47,8 @@ function lastJsonObject(stdout) { return null; } -function guardArgs(config) { - const args = ["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", "gitagent"); - if (home) args.push("--home", home); - if (workspace) args.push("--workspace", workspace); - args.push("--json"); - return args; +function guardArgs(command) { + return ["command", "test", command, "--json"]; } export async function evaluateWithGuard(ctx, config = {}) { @@ -89,22 +62,15 @@ export async function evaluateWithGuard(ctx, config = {}) { ? 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"], + const child = spawn(binary, guardArgs(command), { + stdio: ["ignore", "pipe", "pipe"], env: { ...process.env }, + cwd: workspace, shell: false, }); @@ -125,7 +91,6 @@ export async function evaluateWithGuard(ctx, config = {}) { 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}` }); }); @@ -138,11 +103,8 @@ export async function evaluateWithGuard(ctx, config = {}) { }); return; } - const payload = lastJsonObject(stdout); - finish(guardResponseToHookResult(payload)); + finish(guardResponseToHookResult(lastJsonObject(stdout))); }); - - child.stdin.end(input); }); } From 551cffb0620cce9fde3fb9d88507419653b09efe Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:15:20 -0400 Subject: [PATCH 07/13] HOL_GUARD_GITAGENT_COMMAND_GATE_TEST --- test/hol-guard-integration.test.ts | 34 +++++++++++------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index f9e1242..989aeb1 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -6,24 +6,17 @@ 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" }, - ); + it("maps Guard command floors to GitAgent hook results", () => { + assert.deepEqual(guardResponseToHookResult({ minimum_action: "allow" }), { action: "allow" }); + assert.deepEqual(guardResponseToHookResult({ minimum_action: "monitor" }), { action: "allow" }); assert.deepEqual( guardResponseToHookResult({ - hookSpecificOutput: { - permissionDecision: "deny", - permissionDecisionReason: "blocked by guard", - }, + minimum_action: "review", + classification: { reason: "Guard requires review" }, }), - { action: "block", reason: "blocked by guard" }, - ); - assert.equal( - guardResponseToHookResult({ hookSpecificOutput: { permissionDecision: "ask" } }).action, - "block", + { action: "block", reason: "Guard requires review" }, ); + assert.equal(guardResponseToHookResult({ minimum_action: "block" }).action, "block"); assert.equal(guardResponseToHookResult({ unexpected: true }).action, "block"); }); @@ -34,7 +27,7 @@ describe("HOL Guard GitAgent integration", () => { ); }); - it("invokes HOL Guard with the command payload and blocks a deny", async (t) => { + 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; @@ -45,7 +38,7 @@ describe("HOL Guard GitAgent integration", () => { 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`, + `#!/usr/bin/env node\nimport { writeFileSync } from "node:fs";\nwriteFileSync(process.env.GUARD_CAPTURE, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd() }));\nprocess.stdout.write(JSON.stringify({ minimum_action: "review", classification: { reason: "Guard requires review" } }) + "\\n");\n`, "utf-8", ); await chmod(fixture, 0o755); @@ -57,14 +50,11 @@ describe("HOL Guard GitAgent integration", () => { { 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" }); + assert.deepEqual(result, { action: "block", reason: "Guard requires review" }); const recorded = JSON.parse(await readFile(capture, "utf-8")); - assert.deepEqual(recorded.argv.slice(0, 3), ["hook", "--harness", "gitagent"]); - 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"); + assert.deepEqual(recorded.argv, ["command", "test", "rm -rf ./build", "--json"]); + assert.equal(recorded.cwd, dir); } finally { if (previous === undefined) delete process.env.GUARD_CAPTURE; else process.env.GUARD_CAPTURE = previous; From 52dfbc551845c927e887d5aadc78aabf3916df59 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:16:05 -0400 Subject: [PATCH 08/13] HOL_GUARD_GITAGENT_REMOVE_STALE_HOOK_DOC --- integrations/hol-guard/README.md | 48 -------------------------------- 1 file changed, 48 deletions(-) delete mode 100644 integrations/hol-guard/README.md diff --git a/integrations/hol-guard/README.md b/integrations/hol-guard/README.md deleted file mode 100644 index eda2d08..0000000 --- a/integrations/hol-guard/README.md +++ /dev/null @@ -1,48 +0,0 @@ -# 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`. From eda27dab1e9f5a92f95b81a20478bba069c63ff9 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:36:59 -0400 Subject: [PATCH 09/13] HOL_GUARD_GITAGENT_EXPLICIT_BENIGN_GATE --- integrations/hol-guard/index.mjs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index acb6df1..c7daf97 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -24,10 +24,11 @@ export function guardResponseToHookResult(payload) { } const minimumAction = nonEmptyString(payload.minimum_action)?.toLowerCase(); - if (minimumAction === "allow" || minimumAction === "monitor") { + const explicitlyBenign = payload?.classification?.explicitly_benign === true; + if (minimumAction === "allow" && explicitlyBenign) { return { action: "allow" }; } - if (minimumAction === "review" || minimumAction === "block") { + if (["allow", "monitor", "review", "block"].includes(minimumAction)) { return { action: "block", reason: guardReason(payload) }; } From 2e7c948550de0a78d7079094cb1c3e0cf08a8aaa Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:37:10 -0400 Subject: [PATCH 10/13] HOL_GUARD_GITAGENT_EXPLICIT_BENIGN_GATE_TEST --- test/hol-guard-integration.test.ts | 23 +++++++++++++++++++++-- 1 file changed, 21 insertions(+), 2 deletions(-) diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index 989aeb1..e6c864a 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -7,8 +7,27 @@ import { evaluateWithGuard, guardResponseToHookResult } from "../integrations/ho describe("HOL Guard GitAgent integration", () => { it("maps Guard command floors to GitAgent hook results", () => { - assert.deepEqual(guardResponseToHookResult({ minimum_action: "allow" }), { action: "allow" }); - assert.deepEqual(guardResponseToHookResult({ minimum_action: "monitor" }), { action: "allow" }); + 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", From 6d213d99c876c2881de037ed22f9cce8f6e9ae1c Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:43:00 -0400 Subject: [PATCH 11/13] HOL_GUARD_GITAGENT_CONFIG_ENV --- integrations/hol-guard/index.mjs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index c7daf97..b6e3aaa 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -63,6 +63,11 @@ export async function evaluateWithGuard(ctx, config = {}) { ? 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 = ""; @@ -70,7 +75,7 @@ export async function evaluateWithGuard(ctx, config = {}) { let settled = false; const child = spawn(binary, guardArgs(command), { stdio: ["ignore", "pipe", "pipe"], - env: { ...process.env }, + env: childEnv, cwd: workspace, shell: false, }); From 5a90ab3ca280df8c7ff6f99831c47f852096ec96 Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:43:11 -0400 Subject: [PATCH 12/13] HOL_GUARD_GITAGENT_CONFIG_ENV_TEST --- test/hol-guard-integration.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index e6c864a..ce5f8e9 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -57,7 +57,7 @@ describe("HOL Guard GitAgent integration", () => { 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() }));\nprocess.stdout.write(JSON.stringify({ minimum_action: "review", classification: { reason: "Guard requires review" } }) + "\\n");\n`, + `#!/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" } }) + "\\n");\n`, "utf-8", ); await chmod(fixture, 0o755); @@ -67,13 +67,21 @@ describe("HOL Guard GitAgent integration", () => { try { const result = await evaluateWithGuard( { session_id: "session-1", tool: "cli", args: { command: "rm -rf ./build" } }, - { binary: fixture, workspace: dir, timeout_ms: 2000 }, + { + 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; From dc67619184e5f3a4ecae239d3990d96333d17b6b Mon Sep 17 00:00:00 2001 From: Michael Kantor <6068672+kantorcodes@users.noreply.github.com> Date: Sun, 13 Sep 2026 20:52:14 -0400 Subject: [PATCH 13/13] HOL_GUARD_GITAGENT_JSON_OUTPUT --- integrations/hol-guard/index.mjs | 15 ++++++++++++--- test/hol-guard-integration.test.ts | 2 +- 2 files changed, 13 insertions(+), 4 deletions(-) diff --git a/integrations/hol-guard/index.mjs b/integrations/hol-guard/index.mjs index b6e3aaa..4be3ca5 100644 --- a/integrations/hol-guard/index.mjs +++ b/integrations/hol-guard/index.mjs @@ -35,8 +35,17 @@ export function guardResponseToHookResult(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); +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]); @@ -109,7 +118,7 @@ export async function evaluateWithGuard(ctx, config = {}) { }); return; } - finish(guardResponseToHookResult(lastJsonObject(stdout))); + finish(guardResponseToHookResult(guardJsonObject(stdout))); }); }); } diff --git a/test/hol-guard-integration.test.ts b/test/hol-guard-integration.test.ts index ce5f8e9..6e87343 100644 --- a/test/hol-guard-integration.test.ts +++ b/test/hol-guard-integration.test.ts @@ -57,7 +57,7 @@ describe("HOL Guard GitAgent integration", () => { 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" } }) + "\\n");\n`, + `#!/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);