Summary
On Windows, the agt-governance Claude Code plugin's SessionStart, UserPromptSubmit, and PreToolUse hooks never execute. The plugin's MCP server and slash commands work, and agt_policy_status reports mode: "enforce" with a healthy prompt-defense grade — but no prompt or tool call is ever evaluated, and the audit log is never written. The enforcement layer is silently absent while the status surface reports it as active.
Because the failure happens in the harness's hook launch, upstream of the plugin's own code, the denyOnPolicyError fail-closed setting cannot catch it.
Environment
- Windows 11 Pro (10.0.26200), PowerShell 7
agt-governance plugin v5.0.0, installed via the plugin marketplace (also reproduces from a checkout with --plugin-dir)
- Node.js on PATH (the plugin's MCP server runs fine)
Root cause
agent-governance-claude-code/hooks/hooks.json launches every hook through:
"command": "${CLAUDE_PLUGIN_ROOT}/bin/agt-node"
bin/agt-node is an extensionless POSIX sh script (exec node "$@"). Because the hook definition includes args, Claude Code runs it in exec form: the command is spawned directly with no shell (see the hooks documentation, https://code.claude.com/docs/en/hooks — shell form vs exec form). On win32 that means the sh script fails to launch (ERROR_BAD_EXE_FORMAT), silently. The bin/agt-node.cmd sibling exists but is never referenced — and an explicit .cmd path does not spawn in exec form either (.cmd/.bat require a shell; Node's child_process.spawn refuses them without shell: true since CVE-2024-27980). Only a command resolvable to an .exe (e.g. node) launches.
Evidence
- Session evidence: after a full interactive session on Windows (many prompts and tool calls) with the plugin installed and
mode: "enforce", agt_policy_status reported auditEntries: 0 and %USERPROFILE%\.claude\agt\audit-log.json did not exist. Every UserPromptSubmit should have appended an audit entry (lib/policy.mjs → recordAudit).
- Hook plumbing works when launched correctly: piping a hostile prompt into
hooks/user-prompt-submit.mjs via bin\agt-node.cmd returns decision: "block" and appends a hash-chained audit entry. The bug is only in how the hook is launched.
- Probe under a real headless Claude Code session: three
UserPromptSubmit hooks registered via settings —
- extensionless sh script (the plugin's pattern): did not run
- explicit
.cmd script: did not run
"command": "node" with the script as an arg: ran
- Fix verified: after changing
hooks.json to invoke node directly, a claude -p session with --plugin-dir wrote a prompt.submit audit entry as expected.
Suggested fix
Invoke node directly; agt-node is a pure passthrough to node anyway, so this is equivalent on POSIX and makes Windows work:
{
"type": "command",
"command": "node",
"args": ["${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"],
"cwd": "${CLAUDE_PLUGIN_ROOT}",
"timeout": 30
}
(Same change for session-start.mjs and pre-tool-use.mjs; bin/agt-node{,.cmd} become dead code.)
An alternative is shell form — dropping args and using a single command string ("command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"), which runs through PowerShell on Windows and sh on POSIX — but exec form with node avoids shell-quoting concerns for paths with spaces and is verified working on both spawn paths.
I have this one-line-per-hook fix verified locally on Windows 11 and can open a PR.
Impact
For a toolkit whose core claim is that denied actions are "structurally impossible", the entire Claude Code enforcement surface being silently inactive on one major OS — while agt-status reports enforce mode as healthy — seems worth both the small fix and a status-surface check (e.g. surface "hooks have never fired this session" in agt_policy_status).
Summary
On Windows, the
agt-governanceClaude Code plugin'sSessionStart,UserPromptSubmit, andPreToolUsehooks never execute. The plugin's MCP server and slash commands work, andagt_policy_statusreportsmode: "enforce"with a healthy prompt-defense grade — but no prompt or tool call is ever evaluated, and the audit log is never written. The enforcement layer is silently absent while the status surface reports it as active.Because the failure happens in the harness's hook launch, upstream of the plugin's own code, the
denyOnPolicyErrorfail-closed setting cannot catch it.Environment
agt-governanceplugin v5.0.0, installed via the plugin marketplace (also reproduces from a checkout with--plugin-dir)Root cause
agent-governance-claude-code/hooks/hooks.jsonlaunches every hook through:bin/agt-nodeis an extensionless POSIXshscript (exec node "$@"). Because the hook definition includesargs, Claude Code runs it in exec form: the command is spawned directly with no shell (see the hooks documentation, https://code.claude.com/docs/en/hooks — shell form vs exec form). On win32 that means the sh script fails to launch (ERROR_BAD_EXE_FORMAT), silently. Thebin/agt-node.cmdsibling exists but is never referenced — and an explicit.cmdpath does not spawn in exec form either (.cmd/.batrequire a shell; Node'schild_process.spawnrefuses them withoutshell: truesince CVE-2024-27980). Only a command resolvable to an.exe(e.g.node) launches.Evidence
mode: "enforce",agt_policy_statusreportedauditEntries: 0and%USERPROFILE%\.claude\agt\audit-log.jsondid not exist. EveryUserPromptSubmitshould have appended an audit entry (lib/policy.mjs→recordAudit).hooks/user-prompt-submit.mjsviabin\agt-node.cmdreturnsdecision: "block"and appends a hash-chained audit entry. The bug is only in how the hook is launched.UserPromptSubmithooks registered via settings —.cmdscript: did not run"command": "node"with the script as an arg: ranhooks.jsonto invokenodedirectly, aclaude -psession with--plugin-dirwrote aprompt.submitaudit entry as expected.Suggested fix
Invoke
nodedirectly;agt-nodeis a pure passthrough tonodeanyway, so this is equivalent on POSIX and makes Windows work:{ "type": "command", "command": "node", "args": ["${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"], "cwd": "${CLAUDE_PLUGIN_ROOT}", "timeout": 30 }(Same change for
session-start.mjsandpre-tool-use.mjs;bin/agt-node{,.cmd}become dead code.)An alternative is shell form — dropping
argsand using a single command string ("command": "node ${CLAUDE_PLUGIN_ROOT}/hooks/user-prompt-submit.mjs"), which runs through PowerShell on Windows andshon POSIX — but exec form withnodeavoids shell-quoting concerns for paths with spaces and is verified working on both spawn paths.I have this one-line-per-hook fix verified locally on Windows 11 and can open a PR.
Impact
For a toolkit whose core claim is that denied actions are "structurally impossible", the entire Claude Code enforcement surface being silently inactive on one major OS — while
agt-statusreports enforce mode as healthy — seems worth both the small fix and a status-surface check (e.g. surface "hooks have never fired this session" inagt_policy_status).