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.
- agentbundle/__init__.py +14 -0
- agentbundle/__main__.py +5 -0
- agentbundle/_data/adapter.schema.json +270 -0
- agentbundle/_data/adapter.toml +584 -0
- agentbundle/_data/install-marker.py +1099 -0
- agentbundle/_data/pack.schema.json +152 -0
- agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
- agentbundle/_data/plugin-manifest.schema.json +18 -0
- agentbundle/build/__init__.py +206 -0
- agentbundle/build/__main__.py +8 -0
- agentbundle/build/adapter_root_bins.py +336 -0
- agentbundle/build/adapters/__init__.py +46 -0
- agentbundle/build/adapters/claude_code.py +142 -0
- agentbundle/build/adapters/codex.py +227 -0
- agentbundle/build/adapters/copilot.py +149 -0
- agentbundle/build/adapters/kiro.py +608 -0
- agentbundle/build/adapters/kiro_cli.py +53 -0
- agentbundle/build/adapters/kiro_ide.py +275 -0
- agentbundle/build/contract.py +20 -0
- agentbundle/build/lint_packs.py +555 -0
- agentbundle/build/main.py +596 -0
- agentbundle/build/phase_order.py +40 -0
- agentbundle/build/projections/__init__.py +13 -0
- agentbundle/build/projections/codex_agent_toml.py +232 -0
- agentbundle/build/projections/copilot_agent_md.py +206 -0
- agentbundle/build/projections/copilot_hooks_json.py +142 -0
- agentbundle/build/projections/direct_directory.py +41 -0
- agentbundle/build/projections/hook_id.py +27 -0
- agentbundle/build/projections/kiro_ide_hook.py +256 -0
- agentbundle/build/projections/merge_into_agent_json.py +264 -0
- agentbundle/build/projections/merge_json.py +58 -0
- agentbundle/build/projections/user_merge_json.py +324 -0
- agentbundle/build/scope_rails.py +728 -0
- agentbundle/build/self_host.py +1486 -0
- agentbundle/build/shared_libs.py +309 -0
- agentbundle/build/target_resolver.py +85 -0
- agentbundle/build/tests/__init__.py +0 -0
- agentbundle/build/tests/test_adapter_claude_code.py +275 -0
- agentbundle/build/tests/test_adapter_codex.py +699 -0
- agentbundle/build/tests/test_adapter_copilot.py +91 -0
- agentbundle/build/tests/test_adapter_kiro.py +449 -0
- agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
- agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
- agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
- agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
- agentbundle/build/tests/test_build_ships_seeds.py +78 -0
- agentbundle/build/tests/test_contract.py +582 -0
- agentbundle/build/tests/test_contract_scope.py +224 -0
- agentbundle/build/tests/test_contract_v07.py +191 -0
- agentbundle/build/tests/test_contract_v08.py +230 -0
- agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
- agentbundle/build/tests/test_end_to_end_build.py +227 -0
- agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
- agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
- agentbundle/build/tests/test_lint_packs.py +703 -0
- agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
- agentbundle/build/tests/test_pack_schema.py +265 -0
- agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
- agentbundle/build/tests/test_pack_schema_install.py +305 -0
- agentbundle/build/tests/test_pipeline.py +272 -0
- agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
- agentbundle/build/tests/test_projections_merge_json.py +148 -0
- agentbundle/build/tests/test_scope_rails.py +398 -0
- agentbundle/build/tests/test_security.py +97 -0
- agentbundle/build/tests/test_self_host_check.py +2100 -0
- agentbundle/build/tests/test_shared_libs_projection.py +415 -0
- agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
- agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
- agentbundle/build/tests/test_validate.py +250 -0
- agentbundle/build/validate.py +141 -0
- agentbundle/catalogue.py +164 -0
- agentbundle/cli.py +486 -0
- agentbundle/commands/__init__.py +5 -0
- agentbundle/commands/_common.py +174 -0
- agentbundle/commands/_drop_warning.py +329 -0
- agentbundle/commands/adapt.py +343 -0
- agentbundle/commands/config.py +125 -0
- agentbundle/commands/diff.py +211 -0
- agentbundle/commands/init_state.py +279 -0
- agentbundle/commands/install.py +3026 -0
- agentbundle/commands/list_packs.py +170 -0
- agentbundle/commands/list_targets.py +23 -0
- agentbundle/commands/reconcile.py +161 -0
- agentbundle/commands/render.py +165 -0
- agentbundle/commands/scaffold.py +69 -0
- agentbundle/commands/uninstall.py +294 -0
- agentbundle/commands/upgrade.py +699 -0
- agentbundle/commands/validate.py +688 -0
- agentbundle/config.py +747 -0
- agentbundle/render.py +123 -0
- agentbundle/safety.py +633 -0
- agentbundle/scope.py +319 -0
- agentbundle/user_config.py +284 -0
- agentbundle/version.py +49 -0
- agentbundle-0.2.0.dist-info/METADATA +37 -0
- agentbundle-0.2.0.dist-info/RECORD +99 -0
- agentbundle-0.2.0.dist-info/WHEEL +5 -0
- agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
- 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
|