From 367d1ddee80845a0b97188cc13876b79ad692228 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 27 Aug 2026 12:56:44 +0000 Subject: [PATCH] fix(adapters): Kimi Code and Junie dropped MultiEdit/NotebookEdit content Both docstrings claim Claude Code's wire protocol exactly (Kimi: 'same snake_case fields, same tool_input'; Junie: 'field names follow Claude Code's wire protocol'), so this mirrors claude_code.parse's fallback chain rather than guessing at an unrecorded shape. parse() in each read only tool_input.content/new_string, so a MultiEdit writing a secret via edits[].new_string, or a NotebookEdit writing one via new_source, reached a content-scanning policy with Event.content = None -- the secret-scan deny never fired, on the one write path each adapter explicitly claims to support. Reproduced first (both content and path came back None for both tools on both adapters), then added the same content = content or new_string or new_source or joined(edits) chain, plus a notebook_path path fallback, that claude_code.parse already uses. Regression tests added for both adapters. Signed-off-by: Claude --- CHANGELOG.md | 5 +++++ src/agentseam/adapters/junie.py | 11 +++++++++-- src/agentseam/adapters/kimi_code.py | 10 ++++++++-- tests/test_adapter_junie.py | 19 +++++++++++++++++++ tests/test_adapter_kimi_code.py | 20 ++++++++++++++++++++ 5 files changed, 61 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 248af6a..e3b0717 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] ### Fixed +- Kimi Code and Junie both claim Claude Code's wire protocol exactly (their own docstrings + say so), but `parse()` in each read only `content`/`new_string` -- so MultiEdit's + `edits[].new_string` and NotebookEdit's `new_source`/`notebook_path` were dropped, and a + content policy that already works on claude_code's MultiEdit/NotebookEdit went blind on + these two. Mirrors the fallback chain claude_code.parse already uses. - `install` could **destroy a user's entire config**. `_load` returned `{}` on any parse failure, so the fragment was merged into an empty object and written back, discarding everything the file held. For Junie, whose `config.json` is the whole CLI configuration diff --git a/src/agentseam/adapters/junie.py b/src/agentseam/adapters/junie.py index 1e5451b..b5cc965 100644 --- a/src/agentseam/adapters/junie.py +++ b/src/agentseam/adapters/junie.py @@ -84,13 +84,20 @@ def claims(raw): def parse(raw): ti = raw.get("tool_input") ti = ti if isinstance(ti, dict) else {} + # The docstring stakes everything on Junie's field names following Claude Code's wire + # protocol exactly, so MultiEdit's edits[].new_string and NotebookEdit's new_source get + # the same fallback chain claude_code.parse uses -- not a guess, a claim we already made. + content = ti.get("content") or ti.get("new_string") or ti.get("new_source") or None + if content is None and isinstance(ti.get("edits"), list): + joined = "\n".join(str(e.get("new_string", "")) for e in ti["edits"]) + content = joined or None return Event( AGENT, EVENT_MAP.get(raw.get("hook_event_name"), UNKNOWN), tool=raw.get("tool_name"), command=ti.get("command"), - path=ti.get("file_path") or ti.get("path"), - content=ti.get("content") or ti.get("new_string"), + path=ti.get("file_path") or ti.get("path") or ti.get("notebook_path"), + content=content, output=raw.get("last_assistant_message"), prompt=raw.get("prompt"), session_id=raw.get("session_id"), diff --git a/src/agentseam/adapters/kimi_code.py b/src/agentseam/adapters/kimi_code.py index a1a8238..00e4139 100644 --- a/src/agentseam/adapters/kimi_code.py +++ b/src/agentseam/adapters/kimi_code.py @@ -101,7 +101,13 @@ def claims(raw): def parse(raw): ti = raw.get("tool_input") ti = ti if isinstance(ti, dict) else {} - content = ti.get("content") or ti.get("new_string") or None + # The docstring stakes everything on this envelope being Claude Code's exactly (same + # tool_input), so MultiEdit's edits[].new_string and NotebookEdit's new_source get the + # same fallback chain claude_code.parse uses -- not a guess, a claim we already made. + content = ti.get("content") or ti.get("new_string") or ti.get("new_source") or None + if content is None and isinstance(ti.get("edits"), list): + joined = "\n".join(str(e.get("new_string", "")) for e in ti["edits"]) + content = joined or None out = raw.get("tool_output") if isinstance(out, (dict, list)): out = _json.dumps(out) @@ -113,7 +119,7 @@ def parse(raw): EVENT_MAP.get(raw.get("hook_event_name"), UNKNOWN), tool=raw.get("tool_name"), command=ti.get("command"), - path=ti.get("file_path") or ti.get("path"), + path=ti.get("file_path") or ti.get("path") or ti.get("notebook_path"), content=content, output=out, prompt=raw.get("prompt"), diff --git a/tests/test_adapter_junie.py b/tests/test_adapter_junie.py index 452273d..6dadb61 100644 --- a/tests/test_adapter_junie.py +++ b/tests/test_adapter_junie.py @@ -27,6 +27,25 @@ def test_project_path_separates_junie_from_claude_code(): assert A.adapters.detect(without) == "claude_code" +def test_multiedit_and_notebookedit_content_reach_a_content_policy(): + """The docstring stakes everything on Junie's field names following Claude Code's wire + protocol -- so a policy that already works on claude_code's MultiEdit/NotebookEdit must + not go blind here just because project_path marks it as Junie.""" + multiedit = _pre( + tool_name="MultiEdit", + tool_input={ + "file_path": "AGENTS.md", + "edits": [{"old_string": "x", "new_string": "AWS_SECRET_ACCESS_KEY=akia"}], + }, + ) + _, _, event, _ = A.handle(multiedit, lambda e: Decision.deny("x"), agent="junie") + assert "AWS_SECRET_ACCESS_KEY" in event.content + + notebook = _pre(tool_name="NotebookEdit", tool_input={"notebook_path": "nb.ipynb", "new_source": "SECRET=akia"}) + _, _, event, _ = A.handle(notebook, lambda e: Decision.deny("x"), agent="junie") + assert event.path == "nb.ipynb" and "SECRET" in event.content + + @pytest.mark.parametrize( "decision,expected", [ diff --git a/tests/test_adapter_kimi_code.py b/tests/test_adapter_kimi_code.py index 7e85d87..1cab797 100644 --- a/tests/test_adapter_kimi_code.py +++ b/tests/test_adapter_kimi_code.py @@ -44,6 +44,26 @@ def test_observation_only_events_stay_silent(): assert (text, code) == ("", 0) +def test_multiedit_and_notebookedit_content_reach_a_content_policy(): + """The docstring stakes everything on this envelope being Claude Code's exactly (same + tool_input) -- so a policy that already works on claude_code's MultiEdit/NotebookEdit + must not go blind here just because client_type marks it as Kimi.""" + multiedit = dict(KM_WRITE) + multiedit["tool_name"] = "MultiEdit" + multiedit["tool_input"] = { + "file_path": "AGENTS.md", + "edits": [{"old_string": "x", "new_string": "AWS_SECRET_ACCESS_KEY=akia"}], + } + _, _, event, _ = A.handle(multiedit, deny_all) + assert "AWS_SECRET_ACCESS_KEY" in event.content + + notebook = dict(KM_WRITE) + notebook["tool_name"] = "NotebookEdit" + notebook["tool_input"] = {"notebook_path": "nb.ipynb", "new_source": "SECRET=akia"} + _, _, event, _ = A.handle(notebook, deny_all) + assert event.path == "nb.ipynb" and "SECRET" in event.content + + def test_a_degraded_rewrite_names_the_rewrite_not_a_confirmation(): text, _, _, _ = A.handle(KM_SHELL, lambda e: Decision.rewrite({"command": "true"}, "redact it")) reason = json.loads(text)["hookSpecificOutput"]["permissionDecisionReason"]