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"]