agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,728 @@
1
+ """Contract-level user-scope refusal rails (RFC-0004 Rails A/B/C).
2
+
3
+ The three rails fire **only when a pack declares `"user" ∈
4
+ allowed-scopes`**. Repo-only packs are not inspected. The whole point
5
+ of the rails is to keep content that would not survive the
6
+ user-scope projection out of user-scope packs in the first place.
7
+
8
+ Each rail returns `None` when the pack passes, or a string describing
9
+ the first offending path when the rail refuses. The string carries
10
+ enough context for the caller (`validate` or `install`) to format the
11
+ spec's stderr text — `<pack>: <rail message>` — without per-rail
12
+ formatting code at each call site.
13
+
14
+ Rails:
15
+
16
+ - **Rail A — seeds/.** A pack containing a non-empty `seeds/` directory
17
+ cannot declare `"user" ∈ allowed-scopes` (seeds project to nonsense
18
+ paths under `~`). The detection is filesystem-shaped: any descendant
19
+ file under `<pack>/seeds/` triggers the rail.
20
+
21
+ - **Rail B — hook-shaped primitives.** A pack whose source tree
22
+ contains a non-empty `.apm/hooks/` or `.apm/hook-wiring/` directory
23
+ cannot declare `"user" ∈ allowed-scopes` until the user-scope hook-
24
+ wiring merge story is designed in a follow-up RFC.
25
+
26
+ - **Rail C — `<adapt:NAME>` markers.** A pack declaring `"user" ∈
27
+ allowed-scopes` cannot carry either the legacy UPPER_SNAKE marker
28
+ form `<adapt:[A-Z_][A-Z0-9_]*>` *or* the canonical lowercase-hyphen
29
+ form `<adapt:[a-z][a-z0-9-]*>` in any file under `.apm/skills/`,
30
+ `.apm/agents/`, or `.apm/commands/`. Both casings are recognised
31
+ per `adapt-to-project` spec AC14 (canonical syntax) and AC21
32
+ (cross-spec widening) so a user-scope pack carrying lowercase-
33
+ hyphen markers cannot bypass the rail. The rail walks those
34
+ directories in `sorted(os.walk(...))` order so the first-offending-
35
+ path stderr message is deterministic across runs and platforms.
36
+ Non-UTF-8 (binary) files are skipped silently — they cannot contain
37
+ a textual marker by definition, and forcing them through decoding
38
+ would surface spurious errors on legitimate binaries (icons,
39
+ images, archives).
40
+
41
+ The rails are run by `agentbundle validate <pack>` (pre-publish) and
42
+ re-run by `agentbundle install --scope user` against the resolved pack
43
+ content. Re-running at install time closes the widen-after-publish gap:
44
+ a pack published as `["repo"]` and later flipped to include `"user"`
45
+ cannot install at user scope without passing every rail at install
46
+ time.
47
+ """
48
+
49
+ from __future__ import annotations
50
+
51
+ import os
52
+ import re
53
+ from pathlib import Path
54
+ from typing import Iterable
55
+
56
+
57
+ # Both legacy UPPER_SNAKE and canonical lowercase-hyphen marker forms
58
+ # are recognised per adapt-to-project spec AC14 + AC21. The canonical
59
+ # form is what self_host.resolve_markers writes; the legacy form is
60
+ # tolerated with a one-shot per-file warning during the migration
61
+ # window. Rail C refuses either form in user-scope packs because both
62
+ # would survive into a user-scope projection and bypass the contract.
63
+ _MARKER_REGEX = re.compile(rb"<adapt:(?:[A-Z_][A-Z0-9_]*|[a-z][a-z0-9-]*)>")
64
+
65
+ # The three primitive source directories Rail C walks. `.apm/hooks/` and
66
+ # `.apm/hook-wiring/` are already user-scope-refused by Rail B, so a
67
+ # marker check on them is unreachable. `seeds/` is already
68
+ # user-scope-refused by Rail A, so the marker rail's input never
69
+ # includes `seeds/`. Spec § *Install-scope dimension* pins the list.
70
+ _MARKER_RAIL_DIRS = (".apm/skills", ".apm/agents", ".apm/commands")
71
+
72
+ # Cap per-file inspection size to keep Rail C bounded. A primitive file
73
+ # is human-authored content (SKILL.md, agent body, command); an outsize
74
+ # input under one of the rail directories is either an accident or a
75
+ # DoS attempt against the validate / install path. Files larger than
76
+ # the cap are reported and refused as if they had matched — the rail's
77
+ # job is "decide whether this pack is safe at user scope", and an
78
+ # unreviewable blob in primitive territory is not safe by default.
79
+ _MARKER_RAIL_FILE_CAP_BYTES = 4 * 1024 * 1024 # 4 MiB
80
+
81
+
82
+ def _allows_user(allowed_scopes: Iterable[str]) -> bool:
83
+ """Return True if the pack's allowed-scopes includes `"user"`."""
84
+ return "user" in set(allowed_scopes or ())
85
+
86
+
87
+ def check_seeds(pack_path: Path, allowed_scopes: Iterable[str]) -> str | None:
88
+ """Rail A. Return None on accept; refusal string on refuse.
89
+
90
+ A pack containing a non-empty `seeds/` directory cannot declare
91
+ `"user" ∈ allowed-scopes`.
92
+ """
93
+ if not _allows_user(allowed_scopes):
94
+ return None
95
+ seeds_dir = pack_path / "seeds"
96
+ if not seeds_dir.exists():
97
+ return None
98
+ # followlinks=False so a symlink loop or symlink to outside the
99
+ # pack tree can't extend the rail's reach; consistency with Rail C.
100
+ for root, _dirs, files in os.walk(seeds_dir, followlinks=False):
101
+ if files:
102
+ # Name the first file in sorted order so the message is
103
+ # deterministic across runs (Rail C uses the same rule).
104
+ first = sorted(files)[0]
105
+ rel = Path(root, first).relative_to(pack_path)
106
+ return (
107
+ f"pack carries non-empty seeds/ but declares "
108
+ f'"user" ∈ allowed-scopes; first offender: {rel.as_posix()}'
109
+ )
110
+ return None
111
+
112
+
113
+ def check_hooks(
114
+ pack_path: Path,
115
+ allowed_scopes: Iterable[str],
116
+ user_scope_hooks: bool = False,
117
+ ) -> str | None:
118
+ """Rail B. Return None on accept; refusal string on refuse.
119
+
120
+ A pack containing a non-empty ``.apm/hooks/`` or
121
+ ``.apm/hook-wiring/`` directory cannot declare ``"user" ∈
122
+ allowed-scopes`` **unless** it explicitly opts in via
123
+ ``[pack.install] user-scope-hooks = true`` (RFC-0005 § Rail B —
124
+ user-scope lift). The opt-in is the consent gesture: "yes, my
125
+ hooks land on the adopter's machine outside per-project isolation".
126
+
127
+ The lift here is the validate-side half — T8b threads the same
128
+ flag through install/uninstall so the rail's behaviour stays
129
+ consistent between the two surfaces.
130
+ """
131
+ if not _allows_user(allowed_scopes):
132
+ return None
133
+ if user_scope_hooks:
134
+ # Pack-author opted in — RFC-0005 says the rail lifts. The
135
+ # adapter-side gate (hook-wiring mode declares user-scope
136
+ # capability) is checked later in the projection pipeline
137
+ # (T5/T6); the rail's job is the consent-gesture check.
138
+ return None
139
+ for hook_subdir in (".apm/hooks", ".apm/hook-wiring"):
140
+ candidate = pack_path / hook_subdir
141
+ if not candidate.exists():
142
+ continue
143
+ # followlinks=False — consistent with Rails A and C.
144
+ for root, _dirs, files in os.walk(candidate, followlinks=False):
145
+ if files:
146
+ first = sorted(files)[0]
147
+ rel = Path(root, first).relative_to(pack_path)
148
+ return (
149
+ f"pack carries hook-shaped primitives at {hook_subdir}/ but "
150
+ f'declares "user" ∈ allowed-scopes; first offender: '
151
+ f"{rel.as_posix()}"
152
+ )
153
+ return None
154
+
155
+
156
+ def check_markers(pack_path: Path, allowed_scopes: Iterable[str]) -> str | None:
157
+ """Rail C. Return None on accept; refusal string on refuse.
158
+
159
+ A pack declaring `"user" ∈ allowed-scopes` cannot carry
160
+ `<adapt:NAME>` markers in any file under `.apm/skills/`,
161
+ `.apm/agents/`, or `.apm/commands/`. Walks in deterministic
162
+ `sorted(os.walk(...))` order. Binary files are skipped silently —
163
+ a marker is by construction a UTF-8 byte sequence, and forcing
164
+ binaries through decoding would create spurious failures.
165
+ """
166
+ if not _allows_user(allowed_scopes):
167
+ return None
168
+ for rail_subdir in _MARKER_RAIL_DIRS:
169
+ root_dir = pack_path / rail_subdir
170
+ if not root_dir.exists():
171
+ continue
172
+ for root, dirs, files in os.walk(root_dir, followlinks=False):
173
+ dirs.sort()
174
+ for fname in sorted(files):
175
+ fpath = Path(root, fname)
176
+ try:
177
+ # lstat (not stat) so a `*.md → /dev/zero` symlink
178
+ # surfaces as a symlink at this rail rather than a
179
+ # zero-byte file. Symlinks under `.apm/skills/`,
180
+ # `.apm/agents/`, `.apm/commands/` are not a
181
+ # legitimate primitive shape — refuse them out
182
+ # right so the size cap below can't be defeated by
183
+ # `read_bytes()` traversing the symlink target.
184
+ st = os.lstat(fpath)
185
+ except OSError:
186
+ continue
187
+ from stat import S_ISLNK
188
+
189
+ if S_ISLNK(st.st_mode):
190
+ rel = fpath.relative_to(pack_path)
191
+ return (
192
+ f"pack declares \"user\" ∈ allowed-scopes but "
193
+ f"a primitive entry is a symlink (not a regular "
194
+ f"file); first offender: {rel.as_posix()}"
195
+ )
196
+ size = st.st_size
197
+ if size > _MARKER_RAIL_FILE_CAP_BYTES:
198
+ rel = fpath.relative_to(pack_path)
199
+ return (
200
+ f"pack declares \"user\" ∈ allowed-scopes but "
201
+ f"a primitive file exceeds the marker-rail size cap "
202
+ f"({_MARKER_RAIL_FILE_CAP_BYTES // (1024 * 1024)} MiB); "
203
+ f"first offender: {rel.as_posix()}"
204
+ )
205
+ # Close the lstat→read TOCTOU window with O_NOFOLLOW so
206
+ # the kernel refuses if the entry was swapped for a
207
+ # symlink between the lstat above and this read. The
208
+ # platform check (`hasattr(os, "O_NOFOLLOW")`) is
209
+ # defensive — POSIX always has it; Windows doesn't, but
210
+ # the stdlib-only commitment defers Windows anyway.
211
+ try:
212
+ if hasattr(os, "O_NOFOLLOW"):
213
+ fd = os.open(str(fpath), os.O_RDONLY | os.O_NOFOLLOW)
214
+ try:
215
+ data = os.read(fd, size)
216
+ # Drain any residual bytes appended after lstat.
217
+ while True:
218
+ chunk = os.read(fd, 65536)
219
+ if not chunk:
220
+ break
221
+ if len(data) + len(chunk) > _MARKER_RAIL_FILE_CAP_BYTES:
222
+ rel = fpath.relative_to(pack_path)
223
+ return (
224
+ f"pack declares \"user\" ∈ allowed-scopes "
225
+ f"but a primitive file grew past the "
226
+ f"marker-rail size cap during read; "
227
+ f"first offender: {rel.as_posix()}"
228
+ )
229
+ data += chunk
230
+ finally:
231
+ os.close(fd)
232
+ else:
233
+ data = fpath.read_bytes()
234
+ except OSError:
235
+ # Unreadable file — defer to validate's caller for
236
+ # filesystem-permission errors; don't refuse here.
237
+ continue
238
+ if _is_binary(data):
239
+ continue
240
+ if _MARKER_REGEX.search(data) is not None:
241
+ rel = fpath.relative_to(pack_path)
242
+ return (
243
+ f"pack declares \"user\" ∈ allowed-scopes but "
244
+ f"a primitive file carries <adapt:NAME> markers; "
245
+ f"first offender: {rel.as_posix()}"
246
+ )
247
+ return None
248
+
249
+
250
+ def _is_binary(data: bytes) -> bool:
251
+ """Heuristic: a UTF-8 decode that fails marks the file as binary.
252
+
253
+ The strict-grep contract pins decoding via `errors='strict'` and
254
+ catching `UnicodeDecodeError` — a file that fails to decode cannot
255
+ carry a textual marker. Empty files decode trivially and are not
256
+ binary.
257
+ """
258
+ if not data:
259
+ return False
260
+ try:
261
+ data.decode("utf-8")
262
+ except UnicodeDecodeError:
263
+ return True
264
+ return False
265
+
266
+
267
+ def run_all(
268
+ pack_path: Path,
269
+ allowed_scopes: Iterable[str],
270
+ user_scope_hooks: bool = False,
271
+ ) -> str | None:
272
+ """Run Rails A → B → C in spec order; return first refusal or None.
273
+
274
+ The spec orders them A → B → C so the seeds rail fires before the
275
+ marker rail's input is even computed (the marker rail never sees
276
+ ``seeds/`` content — Rail A already refused the pack if ``seeds/``
277
+ was populated). Use this helper from the CLI's ``install`` and
278
+ ``validate`` surfaces to keep the message order consistent.
279
+
280
+ ``user_scope_hooks`` propagates to Rail B's conditional lift
281
+ (RFC-0005 § Rail B — user-scope lift). Rails A and C ignore it.
282
+ """
283
+ if (result := check_seeds(pack_path, allowed_scopes)) is not None:
284
+ return result
285
+ if (result := check_hooks(pack_path, allowed_scopes, user_scope_hooks)) is not None:
286
+ return result
287
+ if (result := check_markers(pack_path, allowed_scopes)) is not None:
288
+ return result
289
+ return None
290
+
291
+
292
+ # ---------------------------------------------------------------------------
293
+ # T2 (RFC-0005): kiro `attach-to-agent` validate rail.
294
+ #
295
+ # Pure-function shape so unit tests can drive it with in-memory pack-shaped
296
+ # dicts (per the T2 plan's testing approach — no on-disk fixtures). The CLI
297
+ # `validate` command's filesystem-based wrapper lives in `check_kiro_wiring`
298
+ # below; it loads the on-disk pack and dispatches to this in-memory helper.
299
+ # ---------------------------------------------------------------------------
300
+
301
+
302
+ def check_kiro_attach_to_agent(
303
+ pack_name: str,
304
+ wiring_tomls: dict[str, dict],
305
+ agent_basenames: set[str],
306
+ target_adapters: Iterable[str],
307
+ ) -> str | None:
308
+ """In-memory rail. Return refusal string on the first offender, or None.
309
+
310
+ Fires only when ``"kiro" in target_adapters``. For each wiring TOML:
311
+ - missing ``attach-to-agent`` field → refuse,
312
+ - ``attach-to-agent`` value naming an agent the pack does not ship
313
+ (no ``.apm/agents/<value>.md``) → refuse.
314
+
315
+ Refusal text is RFC-0005 § Repo-scope Kiro promotion verbatim:
316
+ ``pack <P>'s hook-wiring <name>.toml does not declare 'attach-to-agent'
317
+ (or names an unknown agent); required for kiro projection``.
318
+
319
+ Arguments:
320
+ pack_name: pack name (substituted into the refusal text).
321
+ wiring_tomls: map of wiring TOML basename (without ``.toml``) → parsed
322
+ TOML body. Iteration order is preserved; the first offender wins.
323
+ agent_basenames: set of agent file basenames (without ``.md``) the
324
+ pack ships under ``.apm/agents/``.
325
+ target_adapters: iterable of adapter names the pack is being
326
+ validated against. No-op when ``kiro`` is absent.
327
+ """
328
+ if "kiro" not in set(target_adapters or ()):
329
+ return None
330
+ for wiring_name, body in wiring_tomls.items():
331
+ attach = body.get("attach-to-agent") if isinstance(body, dict) else None
332
+ if not isinstance(attach, str) or attach not in agent_basenames:
333
+ return (
334
+ f"pack {pack_name}'s hook-wiring {wiring_name}.toml "
335
+ f"does not declare 'attach-to-agent' (or names an unknown "
336
+ f"agent); required for kiro projection"
337
+ )
338
+ return None
339
+
340
+
341
+ def check_kiro_event_vocabulary(
342
+ pack_name: str,
343
+ wiring_tomls: dict[str, dict],
344
+ vocabulary: list[str] | None,
345
+ target_adapters: Iterable[str],
346
+ adapter_name: str,
347
+ ) -> str | None:
348
+ """T6 (RFC-0005): per-adapter event-vocabulary refusal.
349
+
350
+ AC17 and AC17b: a wiring TOML naming an event outside the resolved
351
+ target adapter's declared ``agent-event-vocabulary`` is refused at
352
+ ``validate`` time with the RFC-0005 verbatim text
353
+ ``pack <P>'s hook-wiring <name>.toml uses event '<E>'; not in
354
+ adapter '<adapter>' agent-event-vocabulary``.
355
+
356
+ The check fires only when:
357
+ - the resolved target adapter is in ``target_adapters``, AND
358
+ - that adapter declares ``vocabulary`` (the projection's
359
+ ``agent-event-vocabulary`` field is present).
360
+
361
+ Claude Code's projection does not declare ``agent-event-vocabulary``,
362
+ so a wiring TOML with arbitrary event names projected against
363
+ Claude Code passes ``validate``. The vocabulary refusal is
364
+ per-adapter, not per-RFC (AC17b).
365
+
366
+ Arguments:
367
+ pack_name: substituted into the refusal text.
368
+ wiring_tomls: map of basename → parsed TOML body. First offender
369
+ wins.
370
+ vocabulary: the adapter's declared event-name list, or None when
371
+ the adapter has no such declaration (rail is a no-op).
372
+ target_adapters: iterable of adapter names the pack is being
373
+ validated against.
374
+ adapter_name: the adapter the vocabulary belongs to (substituted
375
+ into the refusal text).
376
+ """
377
+ if adapter_name not in set(target_adapters or ()):
378
+ return None
379
+ if vocabulary is None:
380
+ return None
381
+ allowed = set(vocabulary)
382
+ for wiring_name, body in wiring_tomls.items():
383
+ hooks = body.get("hooks", {}) if isinstance(body, dict) else {}
384
+ if not isinstance(hooks, dict):
385
+ continue
386
+ for event in hooks.keys():
387
+ if event not in allowed:
388
+ return (
389
+ f"pack {pack_name}'s hook-wiring {wiring_name}.toml "
390
+ f"uses event '{event}'; not in adapter '{adapter_name}' "
391
+ f"agent-event-vocabulary"
392
+ )
393
+ return None
394
+
395
+
396
+ def _load_pack_hook_wiring_safely(
397
+ pack_path: Path,
398
+ pack_name: str,
399
+ ) -> tuple[dict, set] | str:
400
+ """Load hook-wiring TOMLs and agent basenames from a pack directory.
401
+
402
+ Applies the security and correctness rails (symlink check + TOML parse)
403
+ without invoking the compatibility rail (``check_kiro_attach_to_agent``).
404
+ Returns either a ``(wiring_tomls, agent_basenames)`` tuple on success, or
405
+ a refusal string for any of three violations:
406
+
407
+ - A symlink under ``.apm/hook-wiring/`` (security rail).
408
+ - A TOML that fails to parse (correctness rail).
409
+ - A symlink under ``.apm/agents/`` (security rail).
410
+
411
+ When the ``.apm/hook-wiring/`` directory does not exist, returns
412
+ ``({}, set())`` so the type signature is uniform for callers — the
413
+ compatibility rail has nothing to check, so agent discovery is skipped
414
+ too (matches the early-return behaviour of ``check_kiro_wiring``).
415
+
416
+ This helper is module-private by convention (underscore prefix) but
417
+ importable by ``validate.py`` so that the security/correctness rails are
418
+ callable independently of the compatibility rail.
419
+ """
420
+ import tomllib
421
+ from stat import S_ISLNK
422
+
423
+ wiring_dir = pack_path / ".apm" / "hook-wiring"
424
+ if not wiring_dir.exists():
425
+ return ({}, set())
426
+
427
+ wiring_tomls: dict[str, dict] = {}
428
+ for entry in sorted(wiring_dir.iterdir()):
429
+ if entry.suffix != ".toml":
430
+ continue
431
+ try:
432
+ st = os.lstat(entry)
433
+ except OSError:
434
+ continue
435
+ if S_ISLNK(st.st_mode):
436
+ rel = entry.relative_to(pack_path)
437
+ return (
438
+ f"pack {pack_name}'s hook-wiring entry is a symlink "
439
+ f"(not a regular file); first offender: {rel.as_posix()}"
440
+ )
441
+ if not entry.is_file():
442
+ continue
443
+ try:
444
+ wiring_tomls[entry.stem] = tomllib.loads(entry.read_text(encoding="utf-8"))
445
+ except (tomllib.TOMLDecodeError, OSError) as exc:
446
+ return (
447
+ f"pack {pack_name}'s hook-wiring {entry.stem}.toml "
448
+ f"failed to parse: {exc}"
449
+ )
450
+
451
+ agents_dir = pack_path / ".apm" / "agents"
452
+ agent_basenames: set[str] = set()
453
+ if agents_dir.exists():
454
+ for entry in sorted(agents_dir.iterdir()):
455
+ if entry.suffix != ".md":
456
+ continue
457
+ try:
458
+ st = os.lstat(entry)
459
+ except OSError:
460
+ continue
461
+ if S_ISLNK(st.st_mode):
462
+ rel = entry.relative_to(pack_path)
463
+ return (
464
+ f"pack {pack_name}'s agent entry is a symlink "
465
+ f"(not a regular file); first offender: {rel.as_posix()}"
466
+ )
467
+ if entry.is_file():
468
+ agent_basenames.add(entry.stem)
469
+
470
+ return (wiring_tomls, agent_basenames)
471
+
472
+
473
+ def check_kiro_wiring(
474
+ pack_path: Path,
475
+ pack_name: str,
476
+ target_adapters: Iterable[str],
477
+ ) -> str | None:
478
+ """Filesystem wrapper around ``check_kiro_attach_to_agent``.
479
+
480
+ Reads ``.apm/hook-wiring/*.toml`` and ``.apm/agents/*.md`` from
481
+ ``pack_path``, parses each wiring TOML with ``tomllib``, and
482
+ dispatches to the in-memory rail. Mirrors rail C's symlink
483
+ discipline: a symlink under either directory is refused — a
484
+ legitimate primitive is a regular file, and following a symlink
485
+ would let a pack reach outside its source tree. A wiring TOML that
486
+ fails to parse counts as a refusal on its own.
487
+ """
488
+ if "kiro" not in set(target_adapters or ()):
489
+ return None
490
+
491
+ loaded = _load_pack_hook_wiring_safely(pack_path, pack_name)
492
+ if isinstance(loaded, str):
493
+ return loaded # security or correctness refusal
494
+ wiring_tomls, agent_basenames = loaded
495
+ return check_kiro_attach_to_agent(
496
+ pack_name,
497
+ wiring_tomls,
498
+ agent_basenames,
499
+ target_adapters,
500
+ )
501
+
502
+
503
+ # ---------------------------------------------------------------------------
504
+ # T-C2 (RFC-0005): kiro-ide-hook validate rail.
505
+ #
506
+ # Five refusal paths covering the RFC's "validate rail" subsection
507
+ # under § *Kiro IDE event hooks — new `kiro-ide-hook` primitive*:
508
+ #
509
+ # 1. Missing required field (`name`, `version`, `when.type`,
510
+ # `then.type`).
511
+ # 2. `when.type` outside the adapter's declared
512
+ # `ide-event-vocabulary`.
513
+ # 3. `then.type` outside the adapter's declared
514
+ # `ide-action-vocabulary`.
515
+ # 4. Malformed placeholder in `then.command` — any `${...}` that
516
+ # does not match `\$\{hook-body:[a-zA-Z0-9_-]+\}` exactly.
517
+ # 5. Unresolvable placeholder — well-formed `${hook-body:<name>}`
518
+ # whose `<name>` is not a same-pack `.apm/hooks/<name>.<ext>`.
519
+ #
520
+ # RFC § Substitution rules clause 1 fences the placeholder scan to
521
+ # `then.command` only; placeholder-shaped text in `then.prompt`
522
+ # (askAgent), `name`, `description`, `when.patterns`, or any other
523
+ # field passes through verbatim.
524
+ #
525
+ # Vocabularies arrive as parameters from the caller — same pattern
526
+ # as `check_kiro_event_vocabulary`. The caller (`commands/validate.py`)
527
+ # loads them from the v0.4 adapter contract once and threads them in;
528
+ # rail-side caching would couple the rail to contract-file location.
529
+ # ---------------------------------------------------------------------------
530
+
531
+
532
+ # Strict placeholder grammar — RFC § Substitution rules clause 4.
533
+ # Closing brace required; inner name matches `[a-zA-Z0-9_-]+` only,
534
+ # so whitespace, slashes, dots, and `..` are all forbidden by
535
+ # construction.
536
+ _HOOK_BODY_PLACEHOLDER_RE = re.compile(r"\$\{hook-body:([a-zA-Z0-9_-]+)\}")
537
+
538
+ # Loose `${...}` matcher used to find anything placeholder-shaped that
539
+ # fails the strict grammar above; an offender that matches this but
540
+ # not the strict regex is a malformed placeholder. We deliberately
541
+ # don't try to match `${...` without a closing brace — that's literal
542
+ # text per shell-syntax convention.
543
+ _ANY_PLACEHOLDER_RE = re.compile(r"\$\{[^}]*\}")
544
+
545
+
546
+ def check_kiro_ide_hook(
547
+ pack_path: Path,
548
+ pack_name: str,
549
+ target_adapters: Iterable[str],
550
+ ide_event_vocabulary: list[str] | None = None,
551
+ ide_action_vocabulary: list[str] | None = None,
552
+ ) -> str | None:
553
+ """T-C2 filesystem rail for the kiro-ide-hook primitive.
554
+
555
+ Walks ``<pack_path>/.apm/kiro-ide-hooks/*.kiro.hook`` in sorted
556
+ order and applies the five refusal paths above. The first
557
+ offender wins; subsequent files are not inspected (matches the
558
+ other Kiro rails' first-offender discipline).
559
+
560
+ Returns:
561
+ ``None`` when every hook passes, or when ``kiro`` is not in
562
+ ``target_adapters``, or when the pack ships no
563
+ ``.apm/kiro-ide-hooks/`` directory.
564
+
565
+ A refusal string in RFC-0005 § *validate rail* verbatim form
566
+ otherwise. The string carries enough context for the caller to
567
+ format the spec's stderr line — ``validate: <pack>: <message>``
568
+ — without per-rail formatting code at each call site.
569
+
570
+ Arguments:
571
+ pack_path: absolute path to the pack root.
572
+ pack_name: pack name (substituted into the refusal text).
573
+ target_adapters: iterable of adapter names the pack is being
574
+ validated against. Rail is a no-op when ``"kiro"`` is absent.
575
+ ide_event_vocabulary: the kiro adapter's declared
576
+ ``ide-event-vocabulary`` from
577
+ ``[adapter.kiro.projections.kiro-ide-hook]``. ``None`` skips
578
+ check 2 (rail becomes a no-op for that field — same shape as
579
+ ``check_kiro_event_vocabulary`` when the adapter declares no
580
+ vocabulary).
581
+ ide_action_vocabulary: same shape, for ``then.type``.
582
+ """
583
+ _kiro_family = {"kiro", "kiro-ide"}
584
+ if not _kiro_family.intersection(set(target_adapters or ())):
585
+ return None
586
+
587
+ import json
588
+ from stat import S_ISLNK
589
+
590
+ hooks_dir = pack_path / ".apm" / "kiro-ide-hooks"
591
+ if not hooks_dir.exists():
592
+ return None
593
+
594
+ # Same-pack hook-body basenames — set up once so check 5 (unresolvable
595
+ # placeholder) can verify a referenced name against shipped files.
596
+ # An empty set is fine — every placeholder will fail check 5, which
597
+ # is the correct semantics (a pack with no hook-bodies cannot
598
+ # reference one).
599
+ hook_body_basenames: set[str] = set()
600
+ hook_body_dir = pack_path / ".apm" / "hooks"
601
+ if hook_body_dir.exists():
602
+ for entry in sorted(hook_body_dir.iterdir()):
603
+ try:
604
+ st = os.lstat(entry)
605
+ except OSError:
606
+ continue
607
+ if S_ISLNK(st.st_mode):
608
+ # Symlinks under .apm/hooks/ are out of scope for this
609
+ # rail; check_hooks (Rail B) is the gate for that
610
+ # surface and it doesn't fire for repo-only packs.
611
+ continue
612
+ if entry.is_file():
613
+ hook_body_basenames.add(entry.stem)
614
+
615
+ allowed_events = set(ide_event_vocabulary) if ide_event_vocabulary is not None else None
616
+ allowed_actions = set(ide_action_vocabulary) if ide_action_vocabulary is not None else None
617
+
618
+ for entry in sorted(hooks_dir.iterdir()):
619
+ if not entry.name.endswith(".kiro.hook"):
620
+ # Other files in .kiro-ide-hooks/ aren't this primitive's
621
+ # responsibility — silently skipped (matches the
622
+ # `*.kiro.hook` filter assumption from RFC Q6).
623
+ continue
624
+ if entry.name == ".kiro.hook":
625
+ # A file named exactly `.kiro.hook` has no bare name to
626
+ # substitute into the projection target — refuse rather
627
+ # than emit a `.kiro/hooks/<pack>/.kiro.hook` whose
628
+ # filename collides with anything else a pack ships.
629
+ return (
630
+ f"pack {pack_name}'s kiro-ide-hook entry has an "
631
+ f"empty bare name; expected <name>.kiro.hook with "
632
+ f"<name> non-empty"
633
+ )
634
+ try:
635
+ st = os.lstat(entry)
636
+ except OSError:
637
+ continue
638
+ if S_ISLNK(st.st_mode):
639
+ rel = entry.relative_to(pack_path)
640
+ return (
641
+ f"pack {pack_name}'s kiro-ide-hook entry is a symlink "
642
+ f"(not a regular file); first offender: {rel.as_posix()}"
643
+ )
644
+ if not entry.is_file():
645
+ continue
646
+
647
+ try:
648
+ body = json.loads(entry.read_text(encoding="utf-8"))
649
+ except (json.JSONDecodeError, UnicodeDecodeError) as exc:
650
+ return (
651
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
652
+ f"failed to parse: {exc}"
653
+ )
654
+ if not isinstance(body, dict):
655
+ return (
656
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
657
+ f"is not a JSON object"
658
+ )
659
+
660
+ # Check 1 — required fields. Order: name → version → when → then →
661
+ # when.type → then.type so the most-likely-missing top-level
662
+ # field surfaces first.
663
+ for required in ("name", "version", "when", "then"):
664
+ if required not in body:
665
+ return (
666
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
667
+ f"is missing required field {required}"
668
+ )
669
+ when = body.get("when")
670
+ then = body.get("then")
671
+ if not isinstance(when, dict) or "type" not in when:
672
+ return (
673
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
674
+ f"is missing required field when.type"
675
+ )
676
+ if not isinstance(then, dict) or "type" not in then:
677
+ return (
678
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
679
+ f"is missing required field then.type"
680
+ )
681
+
682
+ when_type = when["type"]
683
+ then_type = then["type"]
684
+
685
+ # Check 2 — when.type vocabulary.
686
+ if allowed_events is not None and when_type not in allowed_events:
687
+ return (
688
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
689
+ f"uses event '{when_type}'; not in adapter 'kiro' "
690
+ f"ide-event-vocabulary"
691
+ )
692
+
693
+ # Check 3 — then.type vocabulary.
694
+ if allowed_actions is not None and then_type not in allowed_actions:
695
+ return (
696
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
697
+ f"uses action '{then_type}'; not in adapter 'kiro' "
698
+ f"ide-action-vocabulary"
699
+ )
700
+
701
+ # Checks 4 + 5 — placeholder scan. RFC § Substitution rules
702
+ # clause 1 fences this to `then.command` only.
703
+ command = then.get("command")
704
+ if isinstance(command, str):
705
+ # First pass: any `${...}` that doesn't match the strict
706
+ # grammar is malformed (check 4).
707
+ for match in _ANY_PLACEHOLDER_RE.finditer(command):
708
+ literal = match.group(0)
709
+ if not _HOOK_BODY_PLACEHOLDER_RE.fullmatch(literal):
710
+ return (
711
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
712
+ f"contains malformed placeholder '{literal}'; "
713
+ f"expected ${{hook-body:<name>}} with name "
714
+ f"matching [a-zA-Z0-9_-]+"
715
+ )
716
+ # Second pass: well-formed placeholders must resolve to a
717
+ # same-pack hook-body (check 5).
718
+ for match in _HOOK_BODY_PLACEHOLDER_RE.finditer(command):
719
+ name = match.group(1)
720
+ if name not in hook_body_basenames:
721
+ return (
722
+ f"pack {pack_name}'s kiro-ide-hook {entry.name} "
723
+ f"references unknown hook-body "
724
+ f"'${{hook-body:{name}}}'; no such hook-body "
725
+ f"in pack"
726
+ )
727
+
728
+ return None