Skip to content

Commit 290836c

Browse files
fix(install): stop destroying a config we cannot parse (#34)
_load returned {} on any parse failure, so install merged its fragment into an empty object and wrote it back, destroying the user's whole config (starkest for Junie, whose config.json is the entire CLI configuration). _load now tolerates a UTF-8 BOM (utf-8-sig) and raises ConfigUnreadable on anything still unreadable, so install/uninstall preserve the file. installed() stays a query that never raises -- its TOML branch now guards (OSError, UnicodeError) too, matching the JSON branch's ConfigUnreadable handling.
1 parent e920712 commit 290836c

3 files changed

Lines changed: 129 additions & 7 deletions

File tree

CHANGELOG.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ versioning: [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77
## [Unreleased]
88

99
### Fixed
10+
- `install` could **destroy a user's entire config**. `_load` returned `{}` on any parse
11+
failure, so the fragment was merged into an empty object and written back, discarding
12+
everything the file held. For Junie, whose `config.json` is the whole CLI configuration
13+
rather than a hooks-only file, a single stray byte -- a UTF-8 BOM from a Windows editor, a
14+
trailing comma, a half-saved edit -- cost the user their entire config. `_load` now
15+
tolerates a BOM (utf-8-sig, mirroring the runtime stdin fix) and raises `ConfigUnreadable`
16+
on anything still unparseable rather than returning `{}`, so `install` and `uninstall`
17+
stop and preserve the file instead of overwriting it. `installed()` stays a safe query and
18+
reports "not present" rather than raising -- including its TOML branch, which read the file
19+
with no guard and so crashed the query on a non-UTF-8 or unreadable config. This protected
20+
every JSON-config agent, not just Junie.
1021
- Claude Code's `NotebookEdit` is listed in `WRITE_TOOLS`, so the adapter claims to gate it,
1122
but its cell body arrives as `new_source` -- a field `parse()` never read -- so
1223
`Event.content` was `None` and a content policy (secret scan, memory guard) saw nothing at

src/agentseam/install.py

Lines changed: 56 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,51 @@
2121
END = "# <<< agentseam <<<"
2222

2323

24+
class ConfigUnreadable(Exception):
25+
"""An existing config file could not be read or parsed, so it must not be overwritten.
26+
27+
Returning {} here -- as this used to -- meant install merged its fragment into an empty
28+
object and wrote that back, silently DESTROYING everything the user had. For Junie, whose
29+
config.json is the whole CLI configuration rather than a hooks-only file, a single stray
30+
byte (a UTF-8 BOM, a trailing comma, a half-saved edit) cost the user their entire
31+
config. Wiping a file we cannot understand is the exact silent-destruction this library
32+
exists to refuse; the honest move is to stop and say so.
33+
"""
34+
35+
2436
def _load(path):
37+
"""The existing config as a dict. {} only when the file is genuinely absent.
38+
39+
A BOM is tolerated (utf-8-sig), mirroring the runtime's stdin fix -- Windows editors add
40+
one and it is not corruption. Anything still unparseable, or unreadable, raises rather
41+
than returning {}, because the caller is about to write this file back.
42+
"""
2543
if not os.path.exists(path):
2644
return {}
2745
try:
28-
with open(path) as fh:
29-
return json.load(fh)
30-
except (ValueError, OSError):
31-
return {}
46+
with open(path, encoding="utf-8-sig") as fh:
47+
text = fh.read()
48+
except OSError as exc:
49+
raise ConfigUnreadable("cannot read %s: %s" % (path, exc)) from exc
50+
except UnicodeError as exc:
51+
# A file that is not UTF-8/UTF-8-BOM (e.g. UTF-16) raises while decoding here. Wrap it
52+
# as ConfigUnreadable, like a JSON error, so it is preserved and reported through the
53+
# one path -- keeping the exception type consistent -- rather than crashing
54+
# install/uninstall, and installed()'s JSON branch (which catches only
55+
# ConfigUnreadable), with a raw UnicodeDecodeError.
56+
raise ConfigUnreadable("%s exists but is not UTF-8 text (%s); refusing to overwrite it." % (path, exc)) from exc
57+
if not text.strip():
58+
return {} # an empty file is not corruption; treat it as a fresh config
59+
try:
60+
loaded = json.loads(text)
61+
except ValueError as exc:
62+
raise ConfigUnreadable(
63+
"%s exists but is not valid JSON (%s); refusing to overwrite it. "
64+
"Fix or move the file, then re-run." % (path, exc)
65+
) from exc
66+
if not isinstance(loaded, dict):
67+
raise ConfigUnreadable("%s is valid JSON but not an object; refusing to overwrite it." % path)
68+
return loaded
3269

3370

3471
def _dump(path, data):
@@ -183,6 +220,18 @@ def installed(agent, repo_root=".", owner="agentseam"):
183220
if getattr(mod, "CONFIG_FORMAT", "json") == "toml":
184221
if not os.path.exists(path):
185222
return False
186-
with open(path) as fh:
187-
return _block_bounds(fh.read(), owner) is not None
188-
return owner in json.dumps(_load(path))
223+
try:
224+
with open(path, encoding="utf-8-sig") as fh:
225+
text = fh.read()
226+
except (OSError, UnicodeError):
227+
# A query never raises: an unreadable or non-UTF-8 file means our witness is not
228+
# known to be there. This mirrors the JSON branch's ConfigUnreadable handling;
229+
# uninstall() is where an unreadable file must stop, not here.
230+
return False
231+
return _block_bounds(text, owner) is not None
232+
try:
233+
return owner in json.dumps(_load(path))
234+
except ConfigUnreadable:
235+
# A query never raises: if the file cannot be read, our witness is not known to be
236+
# there. uninstall() is where an unreadable file must stop, not here.
237+
return False

tests/test_install.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
import sys
55
from pathlib import Path
66

7+
import pytest
8+
79
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
810

911
from agentseam import adapters
@@ -111,3 +113,63 @@ def test_a_user_scoped_config_path_is_not_nested_under_the_repo(tmp_path, monkey
111113
assert not (tmp_path / "~").exists(), "created a directory literally named ~"
112114
assert Path(written) == tmp_path / ".junie" / "config.json"
113115
assert I.installed("junie", str(tmp_path))
116+
117+
118+
def test_install_never_destroys_a_config_it_cannot_parse(tmp_path, monkeypatch):
119+
"""The data-loss bug: _load returned {} on any parse failure, so install merged its
120+
fragment into an empty object and wrote that back -- wiping the user's whole config.
121+
122+
For Junie, config.json IS the CLI configuration, so a stray byte cost everything. A
123+
UTF-8 BOM (Windows editors add one) is the common trigger and is not corruption, so it
124+
must be tolerated; genuine corruption must stop install, never overwrite.
125+
"""
126+
monkeypatch.setenv("HOME", str(tmp_path))
127+
cfg = tmp_path / ".junie" / "config.json"
128+
cfg.parent.mkdir()
129+
130+
# A BOM-prefixed real config: tolerated, preserved, and wired.
131+
cfg.write_bytes(b"\xef\xbb\xbf" + json.dumps({"theme": "dark", "customModel": "keep-me"}).encode())
132+
I.install("junie", ["pre_tool"], "guard.py")
133+
after = json.loads(cfg.read_text())
134+
assert after["customModel"] == "keep-me" and after["theme"] == "dark", "user settings were destroyed"
135+
assert "hooks" in after, "the hook was not wired"
136+
137+
# Genuine corruption: install refuses rather than clobbering.
138+
cfg.write_text("{ this is not json ,,, }")
139+
with pytest.raises(I.ConfigUnreadable):
140+
I.install("junie", ["pre_tool"], "guard.py")
141+
assert cfg.read_text() == "{ this is not json ,,, }", "a config we could not parse was overwritten"
142+
143+
# An undecodable encoding (UTF-16) is unreadable, not corruption-in-JSON, but must take
144+
# the same preserve-and-report path rather than crashing with a raw UnicodeDecodeError.
145+
cfg.write_bytes(json.dumps({"keep": "me"}).encode("utf-16"))
146+
with pytest.raises(I.ConfigUnreadable):
147+
I.install("junie", ["pre_tool"], "guard.py")
148+
assert cfg.read_bytes()[:2] == b"\xff\xfe", "a UTF-16 config was overwritten"
149+
150+
151+
def test_a_query_never_raises_on_an_unparseable_config(tmp_path, monkeypatch):
152+
"""installed() is a read-only question; a corrupt file means "not known to be there",
153+
not a crash. uninstall() is where an unreadable file must stop instead."""
154+
monkeypatch.setenv("HOME", str(tmp_path))
155+
cfg = tmp_path / ".junie" / "config.json"
156+
cfg.parent.mkdir()
157+
cfg.write_text("{ broken ,,, }")
158+
159+
assert I.installed("junie") is False
160+
with pytest.raises(I.ConfigUnreadable):
161+
I.uninstall("junie")
162+
assert cfg.read_text() == "{ broken ,,, }", "uninstall must not rewrite a file it cannot parse"
163+
164+
165+
def test_a_query_never_raises_on_an_undecodable_toml_config(tmp_path, monkeypatch):
166+
"""The TOML branch of installed() must uphold the same "never raises" contract as the
167+
JSON branch: a config that exists but is not UTF-8 (a UTF-16 file, a stray byte) means
168+
"not known to be there", not a crash. It read the file with no guard before."""
169+
monkeypatch.setenv("HOME", str(tmp_path))
170+
cfg = tmp_path / ".kimi-code" / "config.toml"
171+
cfg.parent.mkdir()
172+
cfg.write_bytes('event = "x"'.encode("utf-16"))
173+
174+
assert I.installed("kimi_code") is False
175+
assert cfg.read_bytes()[:2] == b"\xff\xfe", "a read-only query must not touch the file"

0 commit comments

Comments
 (0)