Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions node/src/commands/hook/posttool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <data>`). Hook input comes from stdin,
// so anything else is unused — discard it instead of erroring out.
.allowUnknownOption()
.allowExcessArguments()
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
.action(async (opts) => {
const format = (opts.format || "claude") as HookFormat;
Expand Down
5 changes: 5 additions & 0 deletions node/src/commands/hook/pretool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 <data>`). Hook input comes from stdin,
// so anything else is unused — discard it instead of erroring out.
.allowUnknownOption()
.allowExcessArguments()
.option("--format <format>", "Output format: claude (default, also Codex/Continue), cursor, gemini, windsurf", "claude")
.action(async (opts) => {
const format = (opts.format || "claude") as HookFormat;
Expand Down
50 changes: 50 additions & 0 deletions node/tests/hook-extra-args.test.ts
Original file line number Diff line number Diff line change
@@ -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 <data>`; 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");
});
});
20 changes: 17 additions & 3 deletions python/rafter_cli/commands/hook.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 <data>`).
# 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",
Expand Down Expand Up @@ -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"),
):
Expand Down Expand Up @@ -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"),
):
Expand Down
46 changes: 46 additions & 0 deletions python/tests/test_hook_extra_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
"""Regression: hook subcommands must tolerate harness-appended flags/args.

Claude Code appends `--hook-json <data>` 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"
2 changes: 2 additions & 0 deletions shared-docs/CLI_SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <data>`. 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.
Expand Down
Loading