Skip to content
Closed
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
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ Only write entries that are worth mentioning to users.

## Unreleased

- Core: Support `updatedInput` for transparent command rewriting in `PreToolUse` hooks — hooks can now rewrite tool input via `hookSpecificOutput.updatedInput`, enabling integrations like RTK to transparently rewrite commands (e.g. `git status` → `rtk git status`) without blocking and retrying

## 1.37.0 (2026-04-20)

- Print: Wait for background tasks before exiting — in one-shot `--print` mode, the process now waits for running background agents to finish and lets the model process their results, instead of exiting and killing them. The wait is capped at `min(max(active_task.timeout_s or agent_task_timeout_s), print_wait_ceiling_s)` (default ceiling 1h); on timeout the tasks are killed and the model gets one more turn via a `<system-reminder>` to summarise before exit
Expand Down
14 changes: 14 additions & 0 deletions docs/en/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ When exiting with code 0, you can output structured JSON for more detailed infor

When `permissionDecision` is `deny`, the operation is blocked and `permissionDecisionReason` is fed back to the LLM.

For `PreToolUse` hooks, you can also provide `updatedInput` to transparently rewrite the tool input before execution:

```json
{
"hookSpecificOutput": {
"updatedInput": {
"command": "rtk git status"
}
}
}
```

When `updatedInput` is provided (and the hook does not deny), the tool receives the updated input instead of the original. This is useful for integrations that need to transparently modify commands without blocking and retrying.

## Hook Script Examples

### Protect Sensitive Files
Expand Down
2 changes: 2 additions & 0 deletions docs/en/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ This page documents the changes in each Kimi Code CLI release.

## Unreleased

- Core: Support `updatedInput` for transparent command rewriting in `PreToolUse` hooks — hooks can now rewrite tool input via `hookSpecificOutput.updatedInput`, enabling integrations like RTK to transparently rewrite commands (e.g. `git status` → `rtk git status`) without blocking and retrying

## 1.37.0 (2026-04-20)

- Print: Wait for background tasks before exiting — in one-shot `--print` mode, the process now waits for running background agents to finish and lets the model process their results, instead of exiting and killing them. The wait is capped at `min(max(active_task.timeout_s or agent_task_timeout_s), print_wait_ceiling_s)` (default ceiling 1h); on timeout the tasks are killed and the model gets one more turn via a `<system-reminder>` to summarise before exit
Expand Down
14 changes: 14 additions & 0 deletions docs/zh/customization/hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,20 @@ Hook 命令从标准输入接收 JSON 格式的上下文信息,包含通用字

当 `permissionDecision` 为 `deny` 时,会阻止操作并将 `permissionDecisionReason` 反馈给 LLM。

对于 `PreToolUse` hooks,你还可以提供 `updatedInput` 来在执行前透明地重写工具输入:

```json
{
"hookSpecificOutput": {
"updatedInput": {
"command": "rtk git status"
}
}
}
```

当提供了 `updatedInput`(且 hook 没有拒绝)时,工具会收到更新后的输入而非原始输入。这对于需要透明修改命令而无需阻塞和重试的集成场景非常有用。

## Hook 脚本示例

### 保护敏感文件
Expand Down
2 changes: 2 additions & 0 deletions docs/zh/release-notes/changelog.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@

## 未发布

- Core:支持 PreToolUse hooks 的 `updatedInput` 透明命令重写——hooks 现在可以通过 `hookSpecificOutput.updatedInput` 重写工具输入,使 RTK 等集成能够透明地重写命令(如 `git status` → `rtk git status`),无需阻塞和重试

## 1.37.0 (2026-04-20)

- Print:退出前等待后台任务完成——在单次 `--print` 模式下,进程现在会等待仍在运行的后台 Agent 完成并让模型处理它们的结果,而不是直接退出并杀死它们。等待时长上限为 `min(max(active_task.timeout_s or agent_task_timeout_s), print_wait_ceiling_s)`(默认上限 1 小时);超时后杀死任务并通过 `<system-reminder>` 给模型最后一轮机会向用户总结后再退出
Expand Down
15 changes: 14 additions & 1 deletion src/kimi_cli/hooks/runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class HookResult:
stderr: str = ""
exit_code: int = 0
timed_out: bool = False
updated_input: dict[str, Any] | None = None


async def run_hook(
Expand Down Expand Up @@ -69,6 +70,7 @@ async def run_hook(
)

# Exit 0 + JSON stdout = structured decision
updated_input: dict[str, Any] | None = None
if exit_code == 0 and stdout.strip():
try:
raw = json.loads(stdout)
Expand All @@ -83,7 +85,18 @@ async def run_hook(
stderr=stderr,
exit_code=0,
)
# Parse updatedInput for transparent command rewriting (e.g. RTK)
if "updatedInput" in hook_output:
_ui = hook_output["updatedInput"]
if isinstance(_ui, dict):
updated_input = cast(dict[str, Any], _ui)
except (json.JSONDecodeError, TypeError):
pass

return HookResult(action="allow", stdout=stdout, stderr=stderr, exit_code=exit_code)
return HookResult(
action="allow",
stdout=stdout,
stderr=stderr,
exit_code=exit_code,
updated_input=updated_input,
)
6 changes: 6 additions & 0 deletions src/kimi_cli/soul/toolset.py
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ async def _call():
),
)

# Apply updatedInput from hooks (transparent rewrite, e.g. RTK)
if isinstance(arguments, dict):
for result in results:
if result.updated_input:
arguments.update(result.updated_input)
Comment thread
zoorpha marked this conversation as resolved.

# --- Execute tool ---
t0 = time.monotonic()
try:
Expand Down
38 changes: 38 additions & 0 deletions tests/hooks/test_runner.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,3 +43,41 @@ async def test_stdin_receives_json():
cmd = """python3 -c "import sys,json; d=json.load(sys.stdin); print(d['tool_name'])" """
result = await run_hook(cmd, {"tool_name": "WriteFile"}, timeout=5)
assert result.stdout.strip() == "WriteFile"


@pytest.mark.asyncio
async def test_json_updated_input_parsing():
cmd = """echo '{"hookSpecificOutput": {"updatedInput": {"command": "rtk git status"}}}' """
result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5)
assert result.action == "allow"
assert result.updated_input == {"command": "rtk git status"}


@pytest.mark.asyncio
async def test_json_deny_takes_precedence_over_updated_input():
cmd = """echo '{"hookSpecificOutput": {"permissionDecision": "deny", "updatedInput": {"command": "rtk git status"}}}' """
result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5)
assert result.action == "block"
assert result.updated_input is None


@pytest.mark.asyncio
async def test_json_invalid_updated_input_is_ignored():
"""Non-dict updatedInput values are ignored to prevent crashes in toolset."""
# string
cmd = """echo '{"hookSpecificOutput": {"updatedInput": "not-a-dict"}}' """
result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5)
assert result.action == "allow"
assert result.updated_input is None

# null
cmd = """echo '{"hookSpecificOutput": {"updatedInput": null}}' """
result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5)
assert result.action == "allow"
assert result.updated_input is None

# list
cmd = """echo '{"hookSpecificOutput": {"updatedInput": [1, 2]}}' """
result = await run_hook(cmd, {"tool_name": "Shell"}, timeout=5)
assert result.action == "allow"
assert result.updated_input is None