diff --git a/node/src/commands/hook/posttool.ts b/node/src/commands/hook/posttool.ts index 50965a7..5e483a5 100644 --- a/node/src/commands/hook/posttool.ts +++ b/node/src/commands/hook/posttool.ts @@ -23,6 +23,11 @@ interface PostToolOutput { export function createHookPosttoolCommand(): Command { return new Command("posttool") .description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)") + // Tolerate extra flags/args the host harness appends to the hook command + // (e.g. Claude Code adds `--hook-json `). Hook input comes from stdin, + // so anything else is unused — discard it instead of erroring out. + .allowUnknownOption() + .allowExcessArguments() .option("--format ", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude") .action(async (opts) => { const format = (opts.format || "claude") as HookFormat; diff --git a/node/src/commands/hook/pretool.ts b/node/src/commands/hook/pretool.ts index 060354b..68024d8 100644 --- a/node/src/commands/hook/pretool.ts +++ b/node/src/commands/hook/pretool.ts @@ -95,6 +95,11 @@ function formatApprovalMessage(command: string, evaluation: CommandEvaluation): export function createHookPretoolCommand(): Command { return new Command("pretool") .description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)") + // Tolerate extra flags/args the host harness appends to the hook command + // (e.g. Claude Code adds `--hook-json `). Hook input comes from stdin, + // so anything else is unused — discard it instead of erroring out. + .allowUnknownOption() + .allowExcessArguments() .option("--format ", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude") .action(async (opts) => { const format = (opts.format || "claude") as HookFormat; diff --git a/node/tests/hook-extra-args.test.ts b/node/tests/hook-extra-args.test.ts new file mode 100644 index 0000000..60c1b31 --- /dev/null +++ b/node/tests/hook-extra-args.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from "vitest"; +import { spawnSync } from "child_process"; +import path from "path"; + +// Regression: the host harness may append extra flags/args to the hook command. +// Claude Code appends `--hook-json `; hook input comes from stdin, so the +// CLI must tolerate (discard) the extra flag instead of erroring (#180). + +const CLI_ENTRY = path.join(path.resolve(__dirname, ".."), "dist", "index.js"); +const PRETOOL_IN = JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls" } }); +const POSTTOOL_IN = JSON.stringify({ tool_name: "Bash", tool_response: { output: "ok" } }); + +function runHook(args: string): { code: number; out: any } { + const r = spawnSync(`node ${CLI_ENTRY} ${args}`, { + input: args.includes("posttool") ? POSTTOOL_IN : PRETOOL_IN, + encoding: "utf-8", + shell: true, + timeout: 30_000, + }); + return { code: r.status ?? -1, out: JSON.parse(r.stdout || "{}") }; +} + +describe("hook commands tolerate harness-appended flags (#180)", () => { + it("pretool accepts --hook-json and still returns a decision", () => { + const { code, out } = runHook("hook pretool --hook-json '{}'"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.permissionDecision).toBe("allow"); + }); + + it("posttool accepts --hook-json", () => { + const { code, out } = runHook("hook posttool --hook-json '{\"x\":1}'"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.hookEventName).toBe("PostToolUse"); + }); + + it("a real option (--format) is still honored, not swallowed", () => { + // gemini "allow" emits an empty object, distinct from the claude shape. + const r = spawnSync(`node ${CLI_ENTRY} hook pretool --format gemini --hook-json '{}'`, { + input: PRETOOL_IN, encoding: "utf-8", shell: true, timeout: 30_000, + }); + expect(r.status).toBe(0); + expect(r.stdout.trim()).toBe("{}"); + }); + + it("still works with no extra flag (unchanged behavior)", () => { + const { code, out } = runHook("hook pretool"); + expect(code).toBe(0); + expect(out.hookSpecificOutput?.permissionDecision).toBe("allow"); + }); +}); diff --git a/python/rafter_cli/commands/hook.py b/python/rafter_cli/commands/hook.py index 026f296..09c059f 100644 --- a/python/rafter_cli/commands/hook.py +++ b/python/rafter_cli/commands/hook.py @@ -13,7 +13,15 @@ from ..core.command_interceptor import CommandInterceptor from ..scanners.regex_scanner import RegexScanner -hook_app = typer.Typer(name="hook", help="Hook handlers for agent platform integration", no_args_is_help=True) +# allow_extra_args / ignore_unknown_options: tolerate extra flags/args the host +# harness appends to the hook command (e.g. Claude Code adds `--hook-json `). +# Hook input comes from stdin, so anything else is unused — discard, don't error. +hook_app = typer.Typer( + name="hook", + help="Hook handlers for agent platform integration", + no_args_is_help=True, + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) _RISK_LABELS = { "critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW", @@ -401,7 +409,10 @@ def _evaluate_write(tool_input: dict) -> dict: return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"} -@hook_app.command("pretool") +@hook_app.command( + "pretool", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) def pretool( format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"), ): @@ -449,7 +460,10 @@ def pretool( _write_pretool_decision({"decision": "allow"}, format) -@hook_app.command("posttool") +@hook_app.command( + "posttool", + context_settings={"allow_extra_args": True, "ignore_unknown_options": True}, +) def posttool( format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"), ): diff --git a/python/tests/test_hook_extra_args.py b/python/tests/test_hook_extra_args.py new file mode 100644 index 0000000..038336b --- /dev/null +++ b/python/tests/test_hook_extra_args.py @@ -0,0 +1,46 @@ +"""Regression: hook subcommands must tolerate harness-appended flags/args. + +Claude Code appends `--hook-json ` to the hook command; hook input comes +from stdin, so the extra flag must be discarded, not rejected (#180). The +context_settings must live on the SUBCOMMAND (pretool/posttool), not only the +hook_app group — a group-level setting does not reach subcommand parsing. +""" +from __future__ import annotations + +import json + +from typer.testing import CliRunner + +from rafter_cli.commands.hook import hook_app + +runner = CliRunner() + +_PRETOOL_IN = '{"tool_name":"Bash","tool_input":{"command":"ls"}}' +_POSTTOOL_IN = '{"tool_name":"Bash","tool_response":{"output":"ok"}}' + + +def test_pretool_tolerates_hook_json(): + r = runner.invoke(hook_app, ["pretool", "--hook-json", "{}"], input=_PRETOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow" + + +def test_posttool_tolerates_hook_json(): + r = runner.invoke(hook_app, ["posttool", "--hook-json", '{"x":1}'], input=_POSTTOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["hookEventName"] == "PostToolUse" + + +def test_real_format_option_still_honored(): + # gemini "allow" emits an empty object — proves --format wasn't swallowed. + r = runner.invoke( + hook_app, ["pretool", "--format", "gemini", "--hook-json", "{}"], input=_PRETOOL_IN + ) + assert r.exit_code == 0, r.output + assert r.stdout.strip() == "{}" + + +def test_no_extra_flag_unchanged(): + r = runner.invoke(hook_app, ["pretool"], input=_PRETOOL_IN) + assert r.exit_code == 0, r.output + assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow" diff --git a/shared-docs/CLI_SPEC.md b/shared-docs/CLI_SPEC.md index c2aaed1..0ee0ef1 100644 --- a/shared-docs/CLI_SPEC.md +++ b/shared-docs/CLI_SPEC.md @@ -945,6 +945,8 @@ On a `git commit` / `git push` (and on `Write`/`Edit`), the hook scans for secre **Bounded stdin read.** Both `hook pretool` and `hook posttool` bound their stdin read so a host that opens the hook's stdin but never writes/closes it (no EOF) cannot wedge the hook: after the bound elapses the hook reads whatever arrived (typically nothing), fails open (`allow` / no-op redaction), and the process **exits** — it does not merely emit a decision and keep running. The bound is **5000 ms** by default and is overridable via `RAFTER_HOOK_STDIN_TIMEOUT_MS` (positive integer milliseconds; non-positive or unparseable values fall back to the default). Both implementations honor the same env var identically. +**Tolerates harness-appended flags.** Hook input arrives on **stdin**, so both subcommands ignore unknown options and extra positional args that an agent platform appends to the hook command — e.g. Claude Code adds `--hook-json `. Such extras are discarded (the hook never errors on them); declared options like `--format` are still parsed normally. + ### rafter hook posttool [OPTIONS] PostToolUse hook handler. Reads tool output from stdin, redacts any secrets found, and writes JSON to stdout.