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 CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 9 additions & 2 deletions src/agentseam/adapters/junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
10 changes: 8 additions & 2 deletions src/agentseam/adapters/kimi_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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"),
Expand Down
19 changes: 19 additions & 0 deletions tests/test_adapter_junie.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
[
Expand Down
20 changes: 20 additions & 0 deletions tests/test_adapter_kimi_code.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down