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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Fixed

- `apm install` and `apm update` no longer fail with `Object of type mappingproxy is not JSON serializable` when a hook carries nested metadata such as `env`; Claude, Codex, Gemini and Antigravity hook files keep that metadata as plain JSON. (by @abhishekacharya200, with regression coverage from @MohammedAlkindi in #3199, closes #3167) (#3211)

## [0.34.0] - 2026-10-16

### Added
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,9 @@ hook groups containing a nested `hooks` array in `.codex/hooks.json`. Kiro
receives its current v1 standalone schema:
`{ "version": "v1", "hooks": [{ "name", "trigger", "matcher", "action" }] }`.
Kiro trigger names are PascalCase and command timeouts remain in seconds.
For Claude, Codex, Gemini, and Antigravity, other handler and hook-group
fields, including nested objects and arrays such as `env`, are written to the
native JSON unchanged.

### Cursor native hooks and Claude import

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,8 @@ hook action under `.kiro/hooks/`.

For Codex, APM wraps flat command entries in hook groups containing a
nested `hooks` array in `.codex/hooks.json`.
Claude, Codex, Gemini, and Antigravity preserve nested handler and
hook-group metadata, including `env` objects and arrays, in native JSON.

For Cursor, APM writes native v1 flat command or prompt handlers using only
verified aliases. Unsupported fields, restrictions and overlapping Claude
Expand Down
42 changes: 42 additions & 0 deletions scripts/architecture_linter/checks/mutation_hook_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,7 @@ def _check_neutral_hook_contract(provider: FactsProvider) -> Iterable[Violation]
*_nhc_config_writes(provider, rule_id),
*_nhc_ownership_markers(provider, rule_id),
*_nhc_drift_projection(provider, rule_id),
*_nhc_native_metadata_thaw(provider, rule_id),
)


Expand Down Expand Up @@ -552,6 +553,47 @@ def _nhc_contract_vocabulary(provider: FactsProvider, rule_id: str) -> tuple[Vio
)


def _nhc_native_metadata_thaw(provider: FactsProvider, rule_id: str) -> tuple[Violation, ...]:
"""Native renderers must thaw frozen hook metadata through ``hook_contract._thaw``.

The IR freezes nested metadata, so a shallow ``dict(...)`` copy or a raw
``raw_entry`` passthrough leaves nested mappings frozen and breaks JSON
serialization (#3167).
"""
renderer = "src/apm_cli/integration/hook_native_formats.py"
facts_by_path, failures = _read_required(provider, rule_id, (_HOOK_CONTRACT, renderer))
findings: list[Violation] = list(failures)
if not failures:
findings.extend(
_require(
_count_regex_lines(facts_by_path[_HOOK_CONTRACT], r"^def _thaw\(") == 1,
rule_id,
_HOOK_CONTRACT,
"_thaw must be defined exactly once by hook_contract",
)
)
for required in ("_thaw(handler.metadata)", "_thaw(binding.metadata)"):
findings.extend(
_require(
_has_fixed(facts_by_path[renderer], required),
rule_id,
renderer,
f"native hook renderers must call {required}",
)
)
findings.extend(
line_pattern_violations(
provider,
rule_id=rule_id,
paths=(renderer,),
pattern=r'dict\((?:handler|binding)\.metadata\)|append\(binding\.metadata\["raw_entry"\]\)',
message="native hook renderers must thaw frozen metadata with hook_contract._thaw",
exempt_marker=EXEMPT_MARKER,
)
)
return tuple(findings)


def _nhc_cursor_edge(provider: FactsProvider, rule_id: str) -> tuple[Violation, ...]:
"""Keep Cursor rendering and import preflight on the canonical native edge."""
renderer = "src/apm_cli/integration/hook_native_formats.py"
Expand Down
9 changes: 9 additions & 0 deletions src/apm_cli/hook_contract.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ def _freeze(value: Any) -> Any:
return value


def _thaw(value: Any) -> Any:
Comment thread
danielmeppiel marked this conversation as resolved.
"""Recursively copy frozen metadata into independent JSON-compatible values."""
if isinstance(value, Mapping):
return {key: _thaw(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [_thaw(item) for item in value]
return value


@dataclass(frozen=True)
class HookHandler:
"""One portable command handler."""
Expand Down
9 changes: 5 additions & 4 deletions src/apm_cli/integration/hook_native_formats.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
HookHandler,
_entries_to_ir,
_handler_to_ir,
_thaw,
hook_handlers,
)

Expand Down Expand Up @@ -350,7 +351,7 @@ def inspect_native_hooks(

def _handler_from_ir(handler: HookHandler, *, timeout_milliseconds: bool) -> dict[str, Any]:
"""Render a portable handler into one native command object."""
result = dict(handler.metadata)
result = _thaw(handler.metadata)
if handler.command is not None:
result["command"] = handler.command
if handler.timeout_seconds is not None:
Expand All @@ -372,9 +373,9 @@ def _render_nested_document(
result: list = []
for binding in document.bindings:
if "raw_entry" in binding.metadata:
result.append(binding.metadata["raw_entry"])
result.append(_thaw(binding.metadata["raw_entry"]))
continue
outer = dict(binding.metadata)
outer = _thaw(binding.metadata)
if binding.matcher is not None or default_matcher is not None:
outer["matcher"] = binding.matcher or default_matcher
outer["hooks"] = [
Expand Down Expand Up @@ -441,7 +442,7 @@ def _to_antigravity_hook_entries(entries: list, event_name: str) -> list:
flat: list[dict[str, Any]] = []
for binding in document.bindings:
if "raw_entry" in binding.metadata:
flat.append(binding.metadata["raw_entry"])
flat.append(_thaw(binding.metadata["raw_entry"]))
continue
for handler in binding.handlers:
rendered = _handler_from_ir(handler, timeout_milliseconds=False)
Expand Down
180 changes: 179 additions & 1 deletion tests/integration/test_architecture_contract_guards.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,43 @@ def test_cursor_native_edge_boundary_rejects_bypass_and_second_owner(
assert any(finding.rule_id == rule.id for finding in result.violations)


def _write_portable_hook_package(tmp_path: Path) -> object:
@pytest.mark.parametrize(
("old", "new"),
[
("result = _thaw(handler.metadata)", "result = dict(handler.metadata)"),
("outer = _thaw(binding.metadata)", "outer = dict(binding.metadata)"),
(
'result.append(_thaw(binding.metadata["raw_entry"]))',
'result.append(binding.metadata["raw_entry"])',
),
(
'flat.append(_thaw(binding.metadata["raw_entry"]))',
'flat.append(binding.metadata["raw_entry"])',
),
],
)
def test_native_hook_renderers_reject_shallow_metadata_copies(old: str, new: str) -> None:
"""Restoring a shallow metadata copy at a render boundary must fail the guard (#3167)."""
root = Path(__file__).parents[2]
path = "src/apm_cli/integration/hook_native_formats.py"
source = (root / path).read_text(encoding="utf-8")
assert old in source
rule = _RULES_BY_ID["mutation_writes.neutral_hook_contract"]
result = run_selected_rules(
root, (rule.id,), source_overrides={path: source.replace(old, new, 1)}
)
assert any(finding.rule_id == rule.id for finding in result.violations)


def test_neutral_hook_contract_passes_on_current_sources() -> None:
"""The unmodified tree satisfies the neutral hook contract rule."""
root = Path(__file__).parents[2]
rule = _RULES_BY_ID["mutation_writes.neutral_hook_contract"]
result = run_selected_rules(root, (rule.id,))
assert [finding for finding in result.violations if finding.rule_id == rule.id] == []


def _write_portable_hook_package(tmp_path: Path, *, nested_metadata: bool = False) -> object:
"""Create one package carrying the flat portable hook shape."""
from apm_cli.models.apm_package import APMPackage, PackageInfo

Expand All @@ -67,6 +103,7 @@ def _write_portable_hook_package(tmp_path: Path) -> object:
"command": "echo check",
"timeout": 10,
"matcher": "fs_write|str_replace",
**(_NESTED_HOOK_METADATA if nested_metadata else {}),
}
]
}
Expand Down Expand Up @@ -119,6 +156,147 @@ def test_codex_native_edge_preserves_nested_entries() -> None:
assert _to_codex_hook_entries(source) == source


_NESTED_HOOK_METADATA = {
"env": {"APM_TEST": "value", "PATHS": ["a", "b"]},
"x-extension": {"tags": [{"name": "t"}], "enabled": True, "weight": 1.5, "note": None},
}


def _rendered_handler(target: str) -> tuple[object, dict]:
"""Render one portable handler carrying nested metadata for one native edge."""
from apm_cli.integration.hook_native_formats import (
_to_antigravity_hook_entries,
_to_claude_hook_entries,
_to_codex_hook_entries,
_to_gemini_hook_entries,
)

source = [{"command": "echo ok", "timeoutSec": 3, **_NESTED_HOOK_METADATA}]
if target == "antigravity-flat":
rendered = _to_antigravity_hook_entries(source, "PreInvocation")
return rendered, rendered[0]
renderers = {
"claude": _to_claude_hook_entries,
"codex": _to_codex_hook_entries,
"gemini": _to_gemini_hook_entries,
"antigravity-nested": lambda entries: _to_antigravity_hook_entries(entries, "PreToolUse"),
}
rendered = renderers[target](source)
return rendered, rendered[0]["hooks"][0]


@pytest.mark.parametrize(
("target", "timeout"),
[
("claude", 3),
("codex", 3),
("gemini", 3000),
("antigravity-nested", 3),
("antigravity-flat", 3),
],
)
def test_native_edges_render_nested_hook_metadata_as_json(target: str, timeout: int) -> None:
"""Nested hook metadata must reach native JSON documents intact (#3167)."""
rendered, handler = _rendered_handler(target)

assert json.loads(json.dumps(rendered)) == rendered
assert handler["command"] == "echo ok"
assert handler["timeout"] == timeout
assert handler["env"] == _NESTED_HOOK_METADATA["env"]
assert handler["x-extension"] == _NESTED_HOOK_METADATA["x-extension"]


def test_native_edges_render_nested_binding_metadata_and_raw_entries_as_json() -> None:
"""Matcher-group metadata and raw entries must round-trip through JSON."""
from apm_cli.integration.hook_native_formats import (
_to_antigravity_hook_entries,
_to_codex_hook_entries,
)

nested = [
{
"matcher": "Bash",
"x-group": {"labels": ["a", {"b": 1}]},
"hooks": [{"type": "command", "command": "echo check", "env": {"K": "v"}}],
}
]
raw = [["opaque", {"inner": {"value": 1}}]]

for rendered, expected in (
(_to_codex_hook_entries(nested), nested),
(_to_codex_hook_entries(raw), raw),
(_to_antigravity_hook_entries(raw, "PreInvocation"), raw),
):
assert json.loads(json.dumps(rendered)) == expected


def test_rendered_hook_metadata_is_independent_of_the_frozen_ir() -> None:
"""Mutating native output must not reach the immutable hook IR."""
from apm_cli.hook_contract import _entries_to_ir
from apm_cli.integration.hook_native_formats import _handler_from_ir

document = _entries_to_ir([{"command": "echo ok", **_NESTED_HOOK_METADATA}])
handler = document.bindings[0].handlers[0]

rendered = _handler_from_ir(handler, timeout_milliseconds=False)
rendered["env"]["APM_TEST"] = "changed"
rendered["env"]["PATHS"].append("c")

assert handler.metadata["env"]["APM_TEST"] == "value"
assert tuple(handler.metadata["env"]["PATHS"]) == ("a", "b")
assert (
_handler_from_ir(handler, timeout_milliseconds=False)["env"] == _NESTED_HOOK_METADATA["env"]
)


def test_gemini_compatibility_conversion_preserves_nested_env() -> None:
"""The in-place Gemini key conversion must keep nested env as plain JSON."""
from apm_cli.integration.hook_native_formats import _copilot_keys_to_gemini

hook = {"bash": "echo ok", "timeoutSec": 2, "env": {"APM_TEST": "value"}}
_copilot_keys_to_gemini(hook)

assert hook["env"] == {"APM_TEST": "value"}
assert hook["timeout"] == 2000
assert json.loads(json.dumps(hook)) == hook


@pytest.mark.parametrize(
("target", "namespace", "event", "timeout"),
[
("claude", "hooks", "PreToolUse", 10),
("codex", "hooks", "PreToolUse", 10),
("gemini", "hooks", "BeforeTool", 10000),
("antigravity", "apm", "PreToolUse", 10),
],
)
def test_integrator_writes_nested_hook_metadata_to_native_json(
tmp_path: Path, target: str, namespace: str, event: str, timeout: int
) -> None:
"""Real native file writes must preserve nested metadata without executing hooks."""
from apm_cli.integration.hook_integrator import HookIntegrator
from apm_cli.integration.targets import KNOWN_TARGETS

profile = KNOWN_TARGETS[target]
(tmp_path / profile.root_dir).mkdir()
package_info = _write_portable_hook_package(tmp_path, nested_metadata=True)
source = package_info.install_path / ".apm" / "hooks" / "check.json"
original = source.read_bytes()

result = HookIntegrator().integrate_hooks_for_target(profile, package_info, tmp_path)

assert result.files_integrated == 1
payload = json.loads((tmp_path / profile.hooks_config_display).read_text(encoding="utf-8"))
binding = payload[namespace][event][0]
handler = binding["hooks"][0]
assert binding["matcher"] == "fs_write|str_replace"
assert handler["command"] == "echo check"
assert handler["timeout"] == timeout
assert handler["env"] == _NESTED_HOOK_METADATA["env"]
assert handler["x-extension"] == _NESTED_HOOK_METADATA["x-extension"]
assert source.read_bytes() == original


def test_claude_edge_nests_flat_portable_hook_entries(tmp_path: Path) -> None:
"""Claude output must wrap portable handlers in matcher/hooks entries."""
from apm_cli.integration.hook_integrator import HookIntegrator
Expand Down
Loading
Loading