Skip to content

Commit f99e51a

Browse files
Add opt-in content-comparison install identity
installed()'s marker-presence default cannot tell a caller whether an installed fragment's CONTENT still matches what install() would render today -- only that something carrying our marker is there. A multi-fragment consumer that independently toggles per-guard coverage claims (chock's installed_pretooluse_policy_ids is the motivating case, per a real historical bug in its own docstring: a stale hook whose guard changed kept reading as "enforced") needs the stronger question answered. installed() gains an opt-in content-comparison mode: pass the same events/command/matcher/fail_closed install() would use to render the fragment right now, and a changed fragment reads as not-installed until the caller re-syncs by calling install() again (which converges). Omitting them keeps the exact default behavior. install.py split into install_config.py (shared config I/O plumbing) and install_identity.py (the installed() query, now including content comparison) to stay under the file-size budget; agentseam.install.* is unchanged for every existing caller. Signed-off-by: Claude <noreply@anthropic.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent f07c490 commit f99e51a

4 files changed

Lines changed: 491 additions & 253 deletions

File tree

src/agentseam/install.py

Lines changed: 37 additions & 253 deletions
Original file line numberDiff line numberDiff line change
@@ -3,203 +3,46 @@
33
Ownership discipline (learned the hard way by a sibling policy engine that shipped this
44
pattern first): every entry we add is marked, so uninstall removes exactly ours and never
55
a user's own hooks, and a re-install replaces rather than duplicates.
6+
7+
`installed()` lives in `install_identity.py` (a query is a different activity from a
8+
write) and is re-exported below -- `agentseam.install.installed` is unchanged for every
9+
existing caller.
610
"""
711

812
from __future__ import annotations
913

10-
import inspect
11-
import json
1214
import os
1315

1416
from . import adapters
15-
16-
MARKER = "_agentseam"
17-
18-
# TOML configs are the user's whole settings document rather than a hooks file, and the
19-
# stdlib can read TOML but not write it. So those get the treatment instruction files
20-
# already use: a marker-delimited block we own, with every byte outside it preserved.
21-
BEGIN = "# >>> agentseam >>>"
22-
END = "# <<< agentseam <<<"
23-
24-
25-
class ConfigUnreadable(Exception):
26-
"""An existing config file could not be read or parsed, so it must not be overwritten.
27-
28-
Returning {} here -- as this used to -- meant install merged its fragment into an empty
29-
object and wrote that back, silently DESTROYING everything the user had. For Junie, whose
30-
config.json is the whole CLI configuration rather than a hooks-only file, a single stray
31-
byte (a UTF-8 BOM, a trailing comma, a half-saved edit) cost the user their entire
32-
config. Wiping a file we cannot understand is the exact silent-destruction this library
33-
exists to refuse; the honest move is to stop and say so.
34-
"""
35-
36-
37-
def _load(path):
38-
"""The existing config as a dict. {} only when the file is genuinely absent.
39-
40-
A BOM is tolerated (utf-8-sig), mirroring the runtime's stdin fix -- Windows editors add
41-
one and it is not corruption. Anything still unparseable, or unreadable, raises rather
42-
than returning {}, because the caller is about to write this file back.
43-
"""
44-
if not os.path.exists(path):
45-
return {}
46-
try:
47-
with open(path, encoding="utf-8-sig") as fh:
48-
text = fh.read()
49-
except OSError as exc:
50-
raise ConfigUnreadable("cannot read %s: %s" % (path, exc)) from exc
51-
except UnicodeError as exc:
52-
# A file that is not UTF-8/UTF-8-BOM (e.g. UTF-16) raises while decoding here. Wrap it
53-
# as ConfigUnreadable, like a JSON error, so it is preserved and reported through the
54-
# one path -- keeping the exception type consistent -- rather than crashing
55-
# install/uninstall, and installed()'s JSON branch (which catches only
56-
# ConfigUnreadable), with a raw UnicodeDecodeError.
57-
raise ConfigUnreadable("%s exists but is not UTF-8 text (%s); refusing to overwrite it." % (path, exc)) from exc
58-
if not text.strip():
59-
return {} # an empty file is not corruption; treat it as a fresh config
60-
try:
61-
loaded = json.loads(text)
62-
except ValueError as exc:
63-
raise ConfigUnreadable(
64-
"%s exists but is not valid JSON (%s); refusing to overwrite it. "
65-
"Fix or move the file, then re-run." % (path, exc)
66-
) from exc
67-
if not isinstance(loaded, dict):
68-
raise ConfigUnreadable("%s is valid JSON but not an object; refusing to overwrite it." % path)
69-
return loaded
70-
71-
72-
def _dump(path, data):
73-
parent = os.path.dirname(path)
74-
if parent:
75-
os.makedirs(parent, exist_ok=True)
76-
with open(path, "w") as fh:
77-
json.dump(data, fh, indent=2, sort_keys=True)
78-
fh.write("\n")
79-
80-
81-
def _mark(obj, owner):
82-
"""Tag every hook entry we own so uninstall is surgical.
83-
84-
Only a dict reached as a LIST ITEM gets tagged -- that is where every adapter's owned
85-
entries actually live, whether that item is a leaf command object (cursor, windsurf) or a
86-
matcher/hooks group wrapping one (claude_code, junie, gemini_cli, grok, devin,
87-
antigravity, tabnine). The top-level container a caller passes in (`{"hooks": {...}}`,
88-
`{"version": 1, "hooks": [...]}`, `{GROUP: group}`) is reached by the initial call, not as
89-
a list item, so it is never tagged -- some vendors' hook-config parsers reject unknown
90-
top-level fields outright. Witnessed live installing here (Codex CLI, 2026-08-27):
91-
"unknown field `_agentseam`, expected `description` or `hooks`", which silently drops
92-
every hook in the file -- the whole file failed to load, not just our entry. A
93-
known-independently vendor bug, not a one-off: openai/codex#30397 documents Codex < 0.143.0
94-
rejecting the entire hooks file over an unexpected top-level `description` key the exact
95-
same way (fixed in #30229), which is why a sibling policy engine's own Codex emitter
96-
never writes one either.
97-
"""
98-
if isinstance(obj, dict):
99-
for v in obj.values():
100-
_mark(v, owner)
101-
elif isinstance(obj, list):
102-
for v in obj:
103-
if isinstance(v, dict):
104-
v[MARKER] = owner
105-
_mark(v, owner)
106-
return obj
107-
108-
109-
def _strip_owned(obj, owner):
110-
"""Remove entries `owner` owns; leave everything else untouched -- including its marker.
111-
112-
The dict branch used to drop MARKER unconditionally, which erased OTHER owners' marks.
113-
That is not cosmetic: ownership is the only thing that makes uninstall surgical, so a
114-
second `install(..., owner="b")` silently un-owned everything "a" had written, and
115-
neither could be uninstalled afterwards. Both sets of entries stayed in the user's real
116-
settings file with nothing left to identify them -- permanent pollution, from the one
117-
operation whose entire purpose is to be reversible.
118-
119-
A marker equal to `owner` is still dropped here, for the dict that is not a list item
120-
and so cannot be removed by the branch above. `_mark` does not put one there today (see
121-
its docstring: some vendors reject unknown top-level fields), but stripping ours if it
122-
ever appears is the conservative half of the pair.
123-
"""
124-
if isinstance(obj, list):
125-
return [_strip_owned(v, owner) for v in obj if not (isinstance(v, dict) and v.get(MARKER) == owner)]
126-
if isinstance(obj, dict):
127-
return {k: _strip_owned(v, owner) for k, v in obj.items() if not (k == MARKER and v == owner)}
128-
return obj
129-
130-
131-
def _merge(base, addition):
132-
for key, value in addition.items():
133-
if isinstance(value, dict) and isinstance(base.get(key), dict):
134-
_merge(base[key], value)
135-
elif isinstance(value, list) and isinstance(base.get(key), list):
136-
base[key].extend(value)
137-
else:
138-
base[key] = value
139-
return base
140-
141-
142-
def _block_bounds(text, owner):
143-
begin, end = "%s %s" % (BEGIN, owner), "%s %s" % (END, owner)
144-
start, stop = text.find(begin), text.find(end)
145-
if start == -1 or stop == -1 or stop < start:
146-
return None
147-
return start, stop + len(end)
148-
149-
150-
def _write_block(path, body, owner):
151-
"""Replace our block, or append one. Everything outside it is left byte-for-byte."""
152-
text = ""
153-
if os.path.exists(path):
154-
with open(path) as fh:
155-
text = fh.read()
156-
block = "%s %s\n%s%s %s" % (BEGIN, owner, body, END, owner)
157-
bounds = _block_bounds(text, owner)
158-
if bounds:
159-
text = text[: bounds[0]] + block + text[bounds[1] :]
160-
else:
161-
text = (text.rstrip("\n") + "\n\n" if text.strip() else "") + block + "\n"
162-
parent = os.path.dirname(path)
163-
if parent:
164-
os.makedirs(parent, exist_ok=True)
165-
with open(path, "w") as fh:
166-
fh.write(text)
167-
168-
169-
def _remove_block(path, owner):
170-
with open(path) as fh:
171-
text = fh.read()
172-
bounds = _block_bounds(text, owner)
173-
if not bounds:
174-
return False
175-
cleaned = (text[: bounds[0]].rstrip("\n") + "\n" + text[bounds[1] :].lstrip("\n")).strip("\n")
176-
with open(path, "w") as fh:
177-
fh.write(cleaned + "\n" if cleaned else "")
178-
return True
179-
180-
181-
def _resolve(mod, repo_root, owner):
182-
"""Where this agent's config lives.
183-
184-
A CONFIG_PATH beginning with `~` is user-scoped and deliberately so -- Junie ignores
185-
hooks from a repository-controlled config, so a project file there would never fire.
186-
Joining it under repo_root produced a literal `./~/` directory: a config written
187-
somewhere no agent reads, indistinguishable at capture time from a vendor whose hooks
188-
do not work.
189-
"""
190-
config = mod.CONFIG_PATH
191-
path = os.path.expanduser(config) if config.startswith("~") else os.path.join(repo_root, config)
192-
return path.replace("*", owner) if "*" in path else path
17+
from .install_config import (
18+
BEGIN,
19+
END,
20+
MARKER,
21+
ConfigUnreadable,
22+
check_wireable,
23+
dump,
24+
fail_closed_kwarg,
25+
load,
26+
mark,
27+
merge,
28+
remove_block,
29+
resolve,
30+
strip_owned,
31+
write_block,
32+
)
33+
from .install_identity import installed
34+
35+
__all__ = ["BEGIN", "END", "ConfigUnreadable", "MARKER", "config_path", "install", "installed", "uninstall"]
19336

19437

19538
def config_path(agent, repo_root=".", owner="agentseam"):
19639
"""Where this agent's hook config lives, resolved the way install resolves it.
19740
19841
Exposed because callers keep needing the answer -- a tool reporting what is wired has to
19942
name the file -- and re-deriving it from CONFIG_PATH loses the `~` and `*` handling that
200-
`_resolve` already gets right.
43+
`resolve` already gets right.
20144
"""
202-
return _resolve(adapters.get(agent), repo_root, owner)
45+
return resolve(adapters.get(agent), repo_root, owner)
20346

20447

20548
def install(agent, events, command, repo_root=".", matcher=None, owner="agentseam", fail_closed=None):
@@ -210,88 +53,29 @@ def install(agent, events, command, repo_root=".", matcher=None, owner="agentsea
21053
failure this library exists to prevent, so it must not be how its own installer behaves.
21154
"""
21255
mod = adapters.get(agent)
213-
# `fail_closed=False` marks this wiring as an OBSERVER, not a gate. It reaches only the
214-
# adapters whose hook_config takes the argument -- today just cursor, the one vendor
215-
# whose config carries a per-hook fail mode. Forwarded by signature rather than by name
216-
# so an adapter that grows the knob later gets it without a change here.
217-
#
218-
# It exists for the capture probe, which always allows: installed as a gate on Cursor it
219-
# inherits failClosed:true, so a probe that cannot launch BLOCKS the user's real command.
220-
# Verification that costs someone a broken session is not worth running.
221-
extra = {}
222-
if fail_closed is not None and "fail_closed" in inspect.signature(mod.hook_config).parameters:
223-
extra["fail_closed"] = fail_closed
224-
unwireable = [e for e in events if e not in getattr(mod, "REVERSE_EVENT_MAP", {})]
225-
if unwireable:
226-
raise ValueError(
227-
"%s has no hook for: %s (it can be wired for: %s)"
228-
% (agent, ", ".join(sorted(unwireable)), ", ".join(sorted(mod.REVERSE_EVENT_MAP)))
229-
)
230-
path = _resolve(mod, repo_root, owner) # e.g. .github/hooks/*.json
56+
check_wireable(mod, agent, events)
57+
extra = fail_closed_kwarg(mod, fail_closed)
58+
path = resolve(mod, repo_root, owner) # e.g. .github/hooks/*.json
23159
if getattr(mod, "CONFIG_FORMAT", "json") == "toml":
232-
_write_block(path, mod.render_config(mod.hook_config(events, command, matcher=matcher, **extra)), owner)
60+
write_block(path, mod.render_config(mod.hook_config(events, command, matcher=matcher, **extra)), owner)
23361
return path
234-
existing = _strip_owned(_load(path), owner) # idempotent: drop our old entries
235-
fragment = _mark(mod.hook_config(events, command, matcher=matcher, **extra), owner)
236-
_dump(path, _merge(existing, fragment))
62+
existing = strip_owned(load(path), owner) # idempotent: drop our old entries
63+
fragment = mark(mod.hook_config(events, command, matcher=matcher, **extra), owner)
64+
dump(path, merge(existing, fragment))
23765
return path
23866

23967

24068
def uninstall(agent, repo_root=".", owner="agentseam"):
24169
"""Remove only our entries. Returns True when the file changed."""
24270
mod = adapters.get(agent)
243-
path = _resolve(mod, repo_root, owner)
71+
path = resolve(mod, repo_root, owner)
24472
if not os.path.exists(path):
24573
return False
24674
if getattr(mod, "CONFIG_FORMAT", "json") == "toml":
247-
return _remove_block(path, owner)
248-
before = _load(path)
249-
after = _strip_owned(before, owner)
75+
return remove_block(path, owner)
76+
before = load(path)
77+
after = strip_owned(before, owner)
25078
if after == before:
25179
return False
252-
_dump(path, after)
80+
dump(path, after)
25381
return True
254-
255-
256-
def _owns_anything(obj, owner):
257-
"""True when some entry carries OUR marker -- not merely our name somewhere in the file.
258-
259-
The witness used to be `owner in json.dumps(...)`, a substring test over the whole
260-
serialised config. Antigravity's config group is literally named "agentseam", the
261-
default owner, so after uninstall the leftover empty group `{"agentseam": {...}}` kept
262-
the witness True forever: `installed()` reported a guard that was gone. Any user string
263-
containing the owner name did the same -- a path, a command, a comment.
264-
265-
Looking for the marker key with our value is the question we actually meant to ask.
266-
"""
267-
if isinstance(obj, dict):
268-
if obj.get(MARKER) == owner:
269-
return True
270-
return any(_owns_anything(v, owner) for v in obj.values())
271-
if isinstance(obj, list):
272-
return any(_owns_anything(v, owner) for v in obj)
273-
return False
274-
275-
276-
def installed(agent, repo_root=".", owner="agentseam"):
277-
"""True when our witness is present in this agent's config."""
278-
mod = adapters.get(agent)
279-
path = _resolve(mod, repo_root, owner)
280-
if getattr(mod, "CONFIG_FORMAT", "json") == "toml":
281-
if not os.path.exists(path):
282-
return False
283-
try:
284-
with open(path, encoding="utf-8-sig") as fh:
285-
text = fh.read()
286-
except (OSError, UnicodeError):
287-
# A query never raises: an unreadable or non-UTF-8 file means our witness is not
288-
# known to be there. This mirrors the JSON branch's ConfigUnreadable handling;
289-
# uninstall() is where an unreadable file must stop, not here.
290-
return False
291-
return _block_bounds(text, owner) is not None
292-
try:
293-
return _owns_anything(_load(path), owner)
294-
except ConfigUnreadable:
295-
# A query never raises: if the file cannot be read, our witness is not known to be
296-
# there. uninstall() is where an unreadable file must stop, not here.
297-
return False

0 commit comments

Comments
 (0)