Skip to content

Commit da09382

Browse files
authored
Merge pull request #180: tolerate harness-appended hook flags (--hook-json)
fix(hook): tolerate --hook-json flag from Claude Code harness
2 parents 4cb78d7 + a82f4d5 commit da09382

6 files changed

Lines changed: 125 additions & 3 deletions

File tree

‎node/src/commands/hook/posttool.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,11 @@ interface PostToolOutput {
2323
export function createHookPosttoolCommand(): Command {
2424
return new Command("posttool")
2525
.description("PostToolUse hook handler (reads stdin, redacts secrets in output, writes JSON to stdout)")
26+
// Tolerate extra flags/args the host harness appends to the hook command
27+
// (e.g. Claude Code adds `--hook-json <data>`). Hook input comes from stdin,
28+
// so anything else is unused — discard it instead of erroring out.
29+
.allowUnknownOption()
30+
.allowExcessArguments()
2631
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
2732
.action(async (opts) => {
2833
const format = (opts.format || "claude") as HookFormat;

‎node/src/commands/hook/pretool.ts‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,11 @@ function formatApprovalMessage(command: string, evaluation: CommandEvaluation):
9595
export function createHookPretoolCommand(): Command {
9696
return new Command("pretool")
9797
.description("PreToolUse hook handler (reads stdin, writes JSON decision to stdout)")
98+
// Tolerate extra flags/args the host harness appends to the hook command
99+
// (e.g. Claude Code adds `--hook-json <data>`). Hook input comes from stdin,
100+
// so anything else is unused — discard it instead of erroring out.
101+
.allowUnknownOption()
102+
.allowExcessArguments()
98103
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
99104
.action(async (opts) => {
100105
const format = (opts.format || "claude") as HookFormat;

‎node/tests/hook-extra-args.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { describe, it, expect } from "vitest";
2+
import { spawnSync } from "child_process";
3+
import path from "path";
4+
5+
// Regression: the host harness may append extra flags/args to the hook command.
6+
// Claude Code appends `--hook-json <data>`; hook input comes from stdin, so the
7+
// CLI must tolerate (discard) the extra flag instead of erroring (#180).
8+
9+
const CLI_ENTRY = path.join(path.resolve(__dirname, ".."), "dist", "index.js");
10+
const PRETOOL_IN = JSON.stringify({ tool_name: "Bash", tool_input: { command: "ls" } });
11+
const POSTTOOL_IN = JSON.stringify({ tool_name: "Bash", tool_response: { output: "ok" } });
12+
13+
function runHook(args: string): { code: number; out: any } {
14+
const r = spawnSync(`node ${CLI_ENTRY} ${args}`, {
15+
input: args.includes("posttool") ? POSTTOOL_IN : PRETOOL_IN,
16+
encoding: "utf-8",
17+
shell: true,
18+
timeout: 30_000,
19+
});
20+
return { code: r.status ?? -1, out: JSON.parse(r.stdout || "{}") };
21+
}
22+
23+
describe("hook commands tolerate harness-appended flags (#180)", () => {
24+
it("pretool accepts --hook-json and still returns a decision", () => {
25+
const { code, out } = runHook("hook pretool --hook-json '{}'");
26+
expect(code).toBe(0);
27+
expect(out.hookSpecificOutput?.permissionDecision).toBe("allow");
28+
});
29+
30+
it("posttool accepts --hook-json", () => {
31+
const { code, out } = runHook("hook posttool --hook-json '{\"x\":1}'");
32+
expect(code).toBe(0);
33+
expect(out.hookSpecificOutput?.hookEventName).toBe("PostToolUse");
34+
});
35+
36+
it("a real option (--format) is still honored, not swallowed", () => {
37+
// gemini "allow" emits an empty object, distinct from the claude shape.
38+
const r = spawnSync(`node ${CLI_ENTRY} hook pretool --format gemini --hook-json '{}'`, {
39+
input: PRETOOL_IN, encoding: "utf-8", shell: true, timeout: 30_000,
40+
});
41+
expect(r.status).toBe(0);
42+
expect(r.stdout.trim()).toBe("{}");
43+
});
44+
45+
it("still works with no extra flag (unchanged behavior)", () => {
46+
const { code, out } = runHook("hook pretool");
47+
expect(code).toBe(0);
48+
expect(out.hookSpecificOutput?.permissionDecision).toBe("allow");
49+
});
50+
});

‎python/rafter_cli/commands/hook.py‎

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,15 @@
1313
from ..core.command_interceptor import CommandInterceptor
1414
from ..scanners.regex_scanner import RegexScanner
1515

16-
hook_app = typer.Typer(name="hook", help="Hook handlers for agent platform integration", no_args_is_help=True)
16+
# allow_extra_args / ignore_unknown_options: tolerate extra flags/args the host
17+
# harness appends to the hook command (e.g. Claude Code adds `--hook-json <data>`).
18+
# Hook input comes from stdin, so anything else is unused — discard, don't error.
19+
hook_app = typer.Typer(
20+
name="hook",
21+
help="Hook handlers for agent platform integration",
22+
no_args_is_help=True,
23+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
24+
)
1725

1826
_RISK_LABELS = {
1927
"critical": "CRITICAL", "high": "HIGH", "medium": "MEDIUM", "low": "LOW",
@@ -401,7 +409,10 @@ def _evaluate_write(tool_input: dict) -> dict:
401409
return {"decision": "deny", "reason": f"Secret detected in {file_path}: {', '.join(names)}"}
402410

403411

404-
@hook_app.command("pretool")
412+
@hook_app.command(
413+
"pretool",
414+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
415+
)
405416
def pretool(
406417
format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"),
407418
):
@@ -449,7 +460,10 @@ def pretool(
449460
_write_pretool_decision({"decision": "allow"}, format)
450461

451462

452-
@hook_app.command("posttool")
463+
@hook_app.command(
464+
"posttool",
465+
context_settings={"allow_extra_args": True, "ignore_unknown_options": True},
466+
)
453467
def posttool(
454468
format: str = typer.Option("claude", "--format", help="Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf"),
455469
):
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
"""Regression: hook subcommands must tolerate harness-appended flags/args.
2+
3+
Claude Code appends `--hook-json <data>` to the hook command; hook input comes
4+
from stdin, so the extra flag must be discarded, not rejected (#180). The
5+
context_settings must live on the SUBCOMMAND (pretool/posttool), not only the
6+
hook_app group — a group-level setting does not reach subcommand parsing.
7+
"""
8+
from __future__ import annotations
9+
10+
import json
11+
12+
from typer.testing import CliRunner
13+
14+
from rafter_cli.commands.hook import hook_app
15+
16+
runner = CliRunner()
17+
18+
_PRETOOL_IN = '{"tool_name":"Bash","tool_input":{"command":"ls"}}'
19+
_POSTTOOL_IN = '{"tool_name":"Bash","tool_response":{"output":"ok"}}'
20+
21+
22+
def test_pretool_tolerates_hook_json():
23+
r = runner.invoke(hook_app, ["pretool", "--hook-json", "{}"], input=_PRETOOL_IN)
24+
assert r.exit_code == 0, r.output
25+
assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow"
26+
27+
28+
def test_posttool_tolerates_hook_json():
29+
r = runner.invoke(hook_app, ["posttool", "--hook-json", '{"x":1}'], input=_POSTTOOL_IN)
30+
assert r.exit_code == 0, r.output
31+
assert json.loads(r.stdout)["hookSpecificOutput"]["hookEventName"] == "PostToolUse"
32+
33+
34+
def test_real_format_option_still_honored():
35+
# gemini "allow" emits an empty object — proves --format wasn't swallowed.
36+
r = runner.invoke(
37+
hook_app, ["pretool", "--format", "gemini", "--hook-json", "{}"], input=_PRETOOL_IN
38+
)
39+
assert r.exit_code == 0, r.output
40+
assert r.stdout.strip() == "{}"
41+
42+
43+
def test_no_extra_flag_unchanged():
44+
r = runner.invoke(hook_app, ["pretool"], input=_PRETOOL_IN)
45+
assert r.exit_code == 0, r.output
46+
assert json.loads(r.stdout)["hookSpecificOutput"]["permissionDecision"] == "allow"

‎shared-docs/CLI_SPEC.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -945,6 +945,8 @@ On a `git commit` / `git push` (and on `Write`/`Edit`), the hook scans for secre
945945

946946
**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.
947947

948+
**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 <data>`. Such extras are discarded (the hook never errors on them); declared options like `--format` are still parsed normally.
949+
948950
### rafter hook posttool [OPTIONS]
949951

950952
PostToolUse hook handler. Reads tool output from stdin, redacts any secrets found, and writes JSON to stdout.

0 commit comments

Comments
 (0)