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
agentbundle/safety.py ADDED
@@ -0,0 +1,633 @@
1
+ """Tier-1/2/3 file-safety primitives, path-jail enforcement, content hashing.
2
+
3
+ The Tier contract is owned by the sibling `distribution-adapters` spec.
4
+ Here we implement it:
5
+
6
+ - Tier-1 — adapter-contract-projected; SHA in state matches on-disk.
7
+ The CLI may write or overwrite.
8
+ - Tier-2 — adapter-contract-projected; on-disk SHA differs from state
9
+ (adopter has edited the file since install). The CLI never
10
+ overwrites; it drops a `<stem>.upstream.<ext>` companion next
11
+ to the original instead.
12
+ - Tier-3 — every path the state file does not record under any pack.
13
+ Read-only to the CLI.
14
+
15
+ `write_jailed` is the only sanctioned write call. Every command that
16
+ writes routes through it so the path-jail check (refusal of any
17
+ `../`-style escape from the configured root) is non-optional.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import enum
23
+ import hashlib
24
+ import os
25
+ import shutil
26
+ import tempfile
27
+ from pathlib import Path
28
+ from typing import Iterable
29
+
30
+ from agentbundle.config import State
31
+
32
+
33
+ class Tier(enum.Enum):
34
+ TIER_1 = "tier-1"
35
+ TIER_2 = "tier-2"
36
+ TIER_3 = "tier-3"
37
+
38
+
39
+ class PathJailError(ValueError):
40
+ """Raised when a write would land outside the configured root."""
41
+
42
+
43
+ class WriteError(OSError):
44
+ """Raised when an otherwise-jailed write fails due to OS errors —
45
+ typically `PermissionError` on a read-only filesystem, `OSError` on
46
+ a full disk, or `NotADirectoryError` when a parent exists as a file.
47
+
48
+ Distinct from `PathJailError` so callers can render different one-line
49
+ stderr messages: jail violations indicate a malicious or buggy pack,
50
+ write errors indicate environment problems on the adopter side.
51
+ """
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Content hashing
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def sha256_bytes(data: bytes) -> str:
60
+ return hashlib.sha256(data).hexdigest()
61
+
62
+
63
+ def sha256_file(path: Path) -> str:
64
+ h = hashlib.sha256()
65
+ with path.open("rb") as fh:
66
+ for chunk in iter(lambda: fh.read(65536), b""):
67
+ h.update(chunk)
68
+ return h.hexdigest()
69
+
70
+
71
+ # ---------------------------------------------------------------------------
72
+ # Tier classification
73
+ # ---------------------------------------------------------------------------
74
+
75
+
76
+ def classify(relpath: str, root: Path, state: State) -> Tier:
77
+ """Classify `relpath` (relative to `root`) per the Tier contract.
78
+
79
+ Resolution:
80
+ 1. If `relpath` is in `state.projected_paths()`:
81
+ - If the file is absent on disk → treat as Tier-1 (about to write).
82
+ - If on-disk SHA == state SHA → Tier-1.
83
+ - Else → Tier-2 (adopter has edited).
84
+ 2. Otherwise → Tier-3.
85
+
86
+ The "absent on disk → Tier-1" rule is important for `install` and
87
+ `render` after a Tier-1 file was deleted by the adopter — re-installing
88
+ rewrites it (it's adapter-contract space; the bundle owns it).
89
+
90
+ **Carve-out for first-install paths:** `commands/install._classify_for_install`
91
+ deliberately bypasses this function for the install command's own walk
92
+ because step 2 here ("not in state → Tier-3") would mark every path
93
+ in a fresh projection as Tier-3 on a first install, suppressing every
94
+ write. The install command's contract is different — every path in
95
+ its incoming projection is adapter-contract space, and the classifier
96
+ only decides overwrite-vs-companion. Do not "fix" this function to do
97
+ what install needs; install's contract differs.
98
+ """
99
+ if relpath not in state.projected_paths():
100
+ return Tier.TIER_3
101
+
102
+ on_disk = root / relpath
103
+ if not on_disk.exists():
104
+ return Tier.TIER_1
105
+
106
+ expected_sha = None
107
+ for ps in state.packs.values():
108
+ sha = ps.file_sha(relpath)
109
+ if sha:
110
+ expected_sha = sha
111
+ break
112
+ if expected_sha is None:
113
+ # Path recorded under a pack table but without a sha entry; we
114
+ # can't prove tier-1 vs tier-2 — be conservative.
115
+ return Tier.TIER_2
116
+
117
+ return Tier.TIER_1 if sha256_file(on_disk) == expected_sha else Tier.TIER_2
118
+
119
+
120
+ # ---------------------------------------------------------------------------
121
+ # .upstream.<ext> companion paths
122
+ # ---------------------------------------------------------------------------
123
+
124
+
125
+ def companion_path(path: Path) -> Path:
126
+ """Compute the `.upstream.<ext>` companion path for `path`.
127
+
128
+ Rules (from the sibling spec § companion semantics):
129
+ - `AGENTS.md` → `AGENTS.upstream.md`
130
+ - `docs/CHARTER.md` → `docs/CHARTER.upstream.md`
131
+ - `Makefile` → `Makefile.upstream` (no extension)
132
+ - `foo.tar.gz` → `foo.tar.upstream.gz` (only the final suffix
133
+ is treated as the ext)
134
+ """
135
+ suffix = path.suffix # always includes the leading "."; empty if none
136
+ if suffix:
137
+ return path.with_name(path.stem + ".upstream" + suffix)
138
+ return path.with_name(path.name + ".upstream")
139
+
140
+
141
+ # ---------------------------------------------------------------------------
142
+ # Path-jail
143
+ # ---------------------------------------------------------------------------
144
+
145
+
146
+ def assert_under(root: Path, target: Path) -> None:
147
+ """Refuse if `target.resolve()` would escape `root.resolve()`.
148
+
149
+ Used by `write_jailed` and by recipe-loading sites that synthesise
150
+ target paths from untrusted data (catalogue URIs, fixture packs).
151
+ The resolved comparison foils `..` traversal and symlink escape.
152
+ """
153
+ root_resolved = root.resolve()
154
+ target_resolved = target.resolve()
155
+ try:
156
+ target_resolved.relative_to(root_resolved)
157
+ except ValueError as exc:
158
+ raise PathJailError(
159
+ f"refusing to write outside repo root: {target_resolved} not under {root_resolved}"
160
+ ) from exc
161
+
162
+
163
+ # ---------------------------------------------------------------------------
164
+ # Windows-portability guard
165
+ # ---------------------------------------------------------------------------
166
+
167
+ # Windows reserves these device names regardless of extension — `CON.txt`
168
+ # is the same as `CON`. The set is case-insensitive and applies at every
169
+ # path segment, so `foo/NUL.log` is also poisonous. We check on every OS
170
+ # because pack content authored on macOS still ships to Windows adopters.
171
+ _WINDOWS_RESERVED_NAMES = frozenset(
172
+ ["CON", "PRN", "AUX", "NUL"]
173
+ + [f"COM{i}" for i in range(1, 10)]
174
+ + [f"LPT{i}" for i in range(1, 10)]
175
+ )
176
+
177
+ # Characters Windows refuses in filenames. The forward slash is the path
178
+ # separator on both POSIX and Windows so it is excluded; the backslash
179
+ # is excluded because we treat it as a separator (callers normalise at
180
+ # the CLI boundary, and `_split_segments` below splits on both).
181
+ _WINDOWS_FORBIDDEN_CHARS = frozenset('<>:"|?*')
182
+
183
+
184
+ def _split_segments(relpath: str) -> list[str]:
185
+ """Split a relpath into segments, treating `/` and `\\` as separators.
186
+
187
+ Empty segments (from leading/trailing/double separators) are dropped
188
+ so we don't flag the empty stem as "trailing space" or "reserved."
189
+
190
+ Defense-in-depth: even though `cli.py:_normalise_path_separators`
191
+ rewrites backslashes at the CLI boundary, this helper accepts both
192
+ separators so a library caller that bypasses the CLI (a test, a
193
+ Python harness) still gets the guard applied correctly. Callers
194
+ should not assume the relpath is pre-normalised.
195
+ """
196
+ out: list[str] = []
197
+ buf: list[str] = []
198
+ for ch in relpath:
199
+ if ch in ("/", "\\"):
200
+ if buf:
201
+ out.append("".join(buf))
202
+ buf = []
203
+ else:
204
+ buf.append(ch)
205
+ if buf:
206
+ out.append("".join(buf))
207
+ return out
208
+
209
+
210
+ def assert_portable_name(relpath: str) -> None:
211
+ """Refuse if any path segment is a Windows-poisonous name.
212
+
213
+ Checks three classes (all OSes, because pack content travels):
214
+ 1. Reserved device names (CON/PRN/AUX/NUL/COM1-9/LPT1-9), case-
215
+ insensitive, matched on the segment **before** any extension —
216
+ Windows treats `CON.txt` and `NUL.tar.gz` the same as the bare
217
+ device name.
218
+ 2. Segments ending in `.` or ` ` — Windows silently strips both
219
+ from filenames at the API layer, so a pack file named `foo. `
220
+ disappears on extract.
221
+ 3. Segments containing `<>:"|?*` — illegal in Windows filenames.
222
+
223
+ Raises `PathJailError` with a one-line message naming the segment.
224
+ """
225
+ for segment in _split_segments(relpath):
226
+ if not segment:
227
+ continue
228
+ # `.` and `..` are traversal markers — `assert_under` handles
229
+ # escape attempts via path resolution. Skip them here so a
230
+ # `../malicious` write reports the jail violation rather than
231
+ # the "trailing dot" guard.
232
+ if segment in (".", ".."):
233
+ continue
234
+ # Class 3: forbidden characters (check first — cheap, no
235
+ # tokenisation needed and gives the most actionable message).
236
+ for ch in segment:
237
+ if ch in _WINDOWS_FORBIDDEN_CHARS:
238
+ raise PathJailError(
239
+ f"refusing path with forbidden character {ch!r} in segment "
240
+ f"{segment!r} (Windows-incompatible): {relpath}"
241
+ )
242
+ # Class 2: trailing dot or space.
243
+ if segment.endswith(".") or segment.endswith(" "):
244
+ raise PathJailError(
245
+ f"refusing path with trailing dot or space in segment "
246
+ f"{segment!r} (Windows strips both silently): {relpath}"
247
+ )
248
+ # Class 1: reserved device name on the pre-extension stem.
249
+ # Windows treats every `<reserved>.<anything>` as the device,
250
+ # so split on the *first* dot rather than the last.
251
+ stem = segment.split(".", 1)[0]
252
+ if stem.upper() in _WINDOWS_RESERVED_NAMES:
253
+ raise PathJailError(
254
+ f"refusing path with Windows-reserved device name "
255
+ f"{stem!r} in segment {segment!r}: {relpath}"
256
+ )
257
+
258
+
259
+ # ---------------------------------------------------------------------------
260
+ # Atomic, jailed writes
261
+ # ---------------------------------------------------------------------------
262
+
263
+
264
+ def write_jailed(
265
+ root: Path,
266
+ relpath: str,
267
+ content: bytes | str,
268
+ *,
269
+ mode: int | None = None,
270
+ scope: str = "repo",
271
+ allowed_prefixes: list[str] | None = None,
272
+ ) -> Path:
273
+ """Write `content` to `root / relpath` atomically; refuse outside-root.
274
+
275
+ Atomic: writes to a sibling tmpfile then `os.replace`s into place. The
276
+ rename is atomic on POSIX within a filesystem; we ensure same-fs by
277
+ putting the tmpfile next to the target.
278
+
279
+ Returns the final on-disk path (resolved). Raises `PathJailError` if
280
+ the resolved target escapes `root`. Caller is responsible for any
281
+ write_atomic backups / Tier-2 companion logic — `write_jailed` is the
282
+ primitive, not the policy.
283
+
284
+ RFC-0004 extensions, generalised at repo scope by RFC-0012:
285
+ ``scope`` — the resolved path must additionally lie under one of
286
+ the entries in ``allowed_prefixes`` (each relative to ``root``).
287
+ The two-layer jail (under the root, under a declared prefix)
288
+ stops a buggy projection rule from passing the basic `..`-escape
289
+ check. **Both scopes** consult ``allowed_prefixes`` now —
290
+ RFC-0012 extends the user-scope rail to repo-scope per-IDE
291
+ projection at the same shape.
292
+
293
+ ``allowed_prefixes`` — the spec's declared list (e.g.
294
+ ``[".claude/", ".agentbundle/"]`` for Claude Code). Each entry
295
+ must end in ``/``; the function compares against the
296
+ relpath-from-root with a directory-boundary check so
297
+ ``allowed-prefixes = [".claude/"]`` rejects a write to a top-
298
+ level file named ``.claudefoo``.
299
+
300
+ When ``allowed_prefixes`` is ``None`` at either scope, the
301
+ per-prefix check is skipped (the bare jail-under-root still
302
+ applies). Passing ``scope="user"`` with ``allowed_prefixes=None``
303
+ remains a programming error — every adopter-facing user-scope
304
+ write must declare its prefix list, so the assertion is the
305
+ forcing function that catches a callsite that forgot.
306
+ """
307
+ if scope == "user" and allowed_prefixes is None:
308
+ # Programming error in CLI code (not adopter-facing). The rail
309
+ # must never silently degrade — surfacing forces the caller to
310
+ # pass the declared prefix list from the adapter's [scope]
311
+ # block. Documented in the spec.
312
+ raise TypeError(
313
+ "allowed_prefixes is required when scope='user'"
314
+ )
315
+
316
+ assert_portable_name(relpath)
317
+ target = root / relpath
318
+ assert_under(root, target)
319
+
320
+ if allowed_prefixes is not None:
321
+ # Check the resolved target is under one of the declared
322
+ # prefixes relative to root. Use directory-boundary matching:
323
+ # the prefix's trailing slash is mandatory, so ``.claude/``
324
+ # admits ``.claude/skills/foo`` but rejects a top-level
325
+ # ``.claude`` file (which would otherwise let a pack replace
326
+ # the directory with a file).
327
+ prefixes = allowed_prefixes
328
+ # Defense-in-depth: the adapter contract schema enforces a
329
+ # trailing slash on every `allowed-prefixes` entry; assert it
330
+ # at runtime so a future caller that bypasses the schema
331
+ # (e.g. constructing the list in code) cannot silently widen
332
+ # the jail. A `.claude` (no slash) prefix would otherwise
333
+ # admit `.claudefoo` — exactly the bug the equality-clause
334
+ # removal was meant to fix.
335
+ if not all(p.endswith("/") for p in prefixes):
336
+ raise PathJailError(
337
+ f"refusing to write at scope {scope!r}: allowed_prefixes "
338
+ f"must each end with '/'; got {prefixes!r}"
339
+ )
340
+ target_relpath = target.resolve().relative_to(root.resolve()).as_posix()
341
+ if not any(target_relpath.startswith(p) for p in prefixes):
342
+ raise PathJailError(
343
+ f"refusing to write outside allowed prefixes for scope "
344
+ f"{scope!r}: {target.resolve()}"
345
+ )
346
+ try:
347
+ target.parent.mkdir(parents=True, exist_ok=True)
348
+ except OSError as exc:
349
+ raise WriteError(
350
+ f"cannot create parent directory {target.parent}: {exc}"
351
+ ) from exc
352
+
353
+ if isinstance(content, str):
354
+ data = content.encode("utf-8")
355
+ else:
356
+ data = content
357
+
358
+ try:
359
+ fd, tmp_str = tempfile.mkstemp(
360
+ prefix=target.name + ".",
361
+ suffix=".tmp",
362
+ dir=str(target.parent),
363
+ )
364
+ except OSError as exc:
365
+ raise WriteError(
366
+ f"cannot write under {target.parent}: {exc}"
367
+ ) from exc
368
+ tmp = Path(tmp_str)
369
+ try:
370
+ with os.fdopen(fd, "wb") as fh:
371
+ fh.write(data)
372
+ if mode is not None:
373
+ os.chmod(tmp, mode)
374
+ os.replace(tmp, target)
375
+ except OSError as exc:
376
+ tmp.unlink(missing_ok=True)
377
+ raise WriteError(
378
+ f"cannot write {target}: {exc}"
379
+ ) from exc
380
+ except Exception:
381
+ tmp.unlink(missing_ok=True)
382
+ raise
383
+ return target.resolve()
384
+
385
+
386
+ _PACK_PRIMITIVE_TYPES: tuple[str, ...] = (
387
+ "skills", "agents", "hooks", "hook-wiring", "commands",
388
+ "shared-libs", "adapter-root-bins",
389
+ )
390
+ """The primitive-type directories under ``<pack>/.apm/`` that the build
391
+ pipeline projects. Used by :func:`_collect_pack_owned_names` to walk a
392
+ pack's source and build the per-pack scan filter.
393
+
394
+ Source of truth is ``_data/adapter.toml``'s ``[primitive.*]`` tables
395
+ (seven entries today: five originals + ``shared-libs`` and
396
+ ``adapter-root-bins`` introduced by RFC-0013). A contract bump that
397
+ adds a new primitive type must extend this tuple, or the per-pack
398
+ scan will silently miss the new type's orphans at install start —
399
+ catalogue-broker packs (e.g. ``credential-brokers``) project
400
+ load-bearing artifacts under ``adapter-root-bins/``."""
401
+
402
+
403
+ def _collect_pack_owned_names(
404
+ pack_dir: Path, pack_name: str
405
+ ) -> tuple[set[str], str]:
406
+ """Return ``(primitive_names, copilot_stem)`` for per-pack scoping.
407
+
408
+ Walks each ``<pack_dir>/.apm/<type>/`` directory (for the five
409
+ canonical primitive types) and collects the basenames of immediate
410
+ children — these are the per-skill / per-agent / per-command
411
+ segments that show up in the on-disk projection. For files (e.g.
412
+ ``agents/foo.md``) the stem is collected (``foo``) so a
413
+ file-shape projection matches; for directories (``skills/foo/``)
414
+ the directory name is collected. Dunder / dotfile children
415
+ (``__pycache__``, ``.DS_Store``) are skipped — they aren't pack
416
+ primitives and would needlessly widen the matched-name set.
417
+
418
+ The two return values are scoped to different positions in the
419
+ relative path under an adapter prefix:
420
+
421
+ - ``primitive_names`` — matched against any **path segment** of
422
+ the relative-to-prefix path. Drives claude-code / kiro /
423
+ codex matching.
424
+ - ``copilot_stem`` (equals ``pack_name``) — matched against the
425
+ **file stem** only when ``len(rel.parts) == 1`` (i.e., the
426
+ Copilot single-file projection at ``<prefix>/<pack>.md``).
427
+
428
+ Splitting the two avoids the cross-pack-name-collision false
429
+ positive: if pack A ships a hook named after pack B's pack name,
430
+ its projection at ``.claude/hooks/<pack-B>.py`` would otherwise
431
+ match pack B's scan via a bare segment-equals-pack-name check.
432
+ The structural restriction (segment for primitives; stem-at-
433
+ depth-1 for Copilot) eliminates this without requiring a new
434
+ catalogue lint.
435
+ """
436
+ primitive_names: set[str] = set()
437
+ apm = pack_dir / ".apm"
438
+ if not apm.is_dir():
439
+ return primitive_names, pack_name
440
+ for ptype in _PACK_PRIMITIVE_TYPES:
441
+ sub = apm / ptype
442
+ if not sub.is_dir():
443
+ continue
444
+ for child in sub.iterdir():
445
+ if child.name.startswith(("_", ".")):
446
+ continue
447
+ primitive_names.add(child.stem if child.is_file() else child.name)
448
+ return primitive_names, pack_name
449
+
450
+
451
+ def scan_for_pack_artifacts(
452
+ root: Path,
453
+ allowed_prefixes: list[str],
454
+ *,
455
+ pack_dir: Path | None = None,
456
+ pack_name: str | None = None,
457
+ ) -> list[Path]:
458
+ """Return on-disk files under ``<root>/<prefix>/`` for each prefix.
459
+
460
+ Read-only; walks every ``<root>/<prefix>/`` and returns the files
461
+ found. No state mutation. Used by RFC-0012 § *Reliability* — the
462
+ orphan-projection refusal at install start compares this list
463
+ against ``state.toml``; a non-empty result with no state row for
464
+ the pack means a prior install crashed mid-write.
465
+
466
+ **Per-pack scoping (preferred).** When ``pack_dir`` and
467
+ ``pack_name`` are both provided, the result is narrowed via a
468
+ heuristic stand-in for full render-driven ownership: a file's
469
+ relative-to-prefix path is matched against names walked from the
470
+ pack's source. Specifically:
471
+
472
+ - ``claude-code`` / ``kiro``: ``<prefix>/<type>/<primitive>/<file>``
473
+ — the ``<primitive>`` path segment is matched against
474
+ primitive names walked from ``<pack_dir>/.apm/<type>/``.
475
+ - ``codex``: ``<prefix>/<primitive>/<file>`` (prefix ends in
476
+ ``skills/``) — same segment match.
477
+ - ``copilot``: ``<prefix>/<pack>.md`` — the **file stem** is
478
+ matched against ``pack_name``, but only when the relative
479
+ path is a single segment (``len(rel.parts) == 1``); this
480
+ scopes the stem rule to Copilot's flat projection and avoids
481
+ a cross-pack-name-collision false positive at other adapters.
482
+
483
+ The heuristic admits a narrow residual false-positive surface:
484
+ if a foreign pack ships a primitive whose stem matches a primitive
485
+ name in this pack, the foreign file matches via case (b). The
486
+ path-jail enforces prefix containment (not primitive-name
487
+ uniqueness across packs); two packs landing files at the same
488
+ on-disk path would conflict at install-time write, but the scan
489
+ runs *before* that point. The depth-1 restriction on case (c)
490
+ closes the larger cross-pack-name-collision surface (foreign
491
+ primitive named after this pack's pack-name); the remaining
492
+ stem-in-primitives risk is bounded by catalogue conventions
493
+ around per-pack-unique primitive naming.
494
+
495
+ **Legacy mode.** When ``pack_dir``/``pack_name`` are omitted, the
496
+ helper preserves its pre-2026-05-26 adapter-prefix-only scoping
497
+ for any external caller that didn't migrate.
498
+
499
+ Each ``prefix`` is expected to end in ``/`` (matching the
500
+ contract's `allowed-prefixes.<scope>` convention). Missing prefix
501
+ directories are skipped silently — a greenfield install has no
502
+ on-disk artifacts and that's the expected case, not an error.
503
+
504
+ Results are sorted by path for stable test comparison and stable
505
+ stderr ordering when callers print the list.
506
+ """
507
+ primitive_names: set[str] | None = None
508
+ copilot_stem: str | None = None
509
+ if pack_dir is not None and pack_name is not None:
510
+ primitive_names, copilot_stem = _collect_pack_owned_names(
511
+ pack_dir, pack_name
512
+ )
513
+
514
+ out: list[Path] = []
515
+ for prefix in allowed_prefixes:
516
+ base = root / prefix
517
+ if not base.exists():
518
+ continue
519
+ for entry in base.rglob("*"):
520
+ if not entry.is_file():
521
+ continue
522
+ if primitive_names is not None:
523
+ rel = entry.relative_to(base)
524
+ # Segment match: any path component matches a primitive
525
+ # directory name. File-shape primitives (e.g.
526
+ # ``agents/foo.md``) need stem-vs-primitive-names too —
527
+ # the path part is ``foo.md`` but the collected name is
528
+ # ``foo``.
529
+ primitive_hit = (
530
+ bool(set(rel.parts) & primitive_names)
531
+ or entry.stem in primitive_names
532
+ )
533
+ # Copilot single-file projection only — scoped to the
534
+ # depth-1 case so a cross-pack primitive named after
535
+ # another pack's pack-name doesn't match here.
536
+ copilot_hit = (
537
+ copilot_stem is not None
538
+ and len(rel.parts) == 1
539
+ and entry.stem == copilot_stem
540
+ )
541
+ if not (primitive_hit or copilot_hit):
542
+ continue
543
+ out.append(entry)
544
+ return sorted(out)
545
+
546
+
547
+ def write_companion(root: Path, relpath: str, content: bytes | str) -> Path:
548
+ """Write a `<stem>.upstream.<ext>` companion next to `relpath`."""
549
+ companion = companion_path(Path(relpath))
550
+ return write_jailed(root, str(companion), content)
551
+
552
+
553
+ def copy_jailed(root: Path, source: Path, relpath: str) -> Path:
554
+ """Copy a file into the jailed root, preserving mode (mirrors shutil.copy2)."""
555
+ assert_portable_name(relpath)
556
+ target = root / relpath
557
+ assert_under(root, target)
558
+ target.parent.mkdir(parents=True, exist_ok=True)
559
+ shutil.copy2(source, target, follow_symlinks=False)
560
+ return target.resolve()
561
+
562
+
563
+ # ---------------------------------------------------------------------------
564
+ # Helpers used by commands that walk projections
565
+ # ---------------------------------------------------------------------------
566
+
567
+
568
+ def projected_files_in_state(state: State, pack_name: str) -> Iterable[str]:
569
+ ps = state.packs.get(pack_name)
570
+ if ps is None:
571
+ return ()
572
+ return tuple(ps.files.keys())
573
+
574
+
575
+ # ---------------------------------------------------------------------------
576
+ # User-scope artifact root (RFC-0004)
577
+ # ---------------------------------------------------------------------------
578
+
579
+
580
+ def user_state_path(home: Path | None = None) -> Path:
581
+ """Return the user-scope state file path: `~/.agentbundle/state.toml`.
582
+
583
+ Per RFC-0004 § *State file per scope*, user-scope artifacts live inside
584
+ the namespaced `~/.agentbundle/` dot-directory — not as bare dotfiles
585
+ in `$HOME`. The dot-directory is the future home for
586
+ `.adapt-discovery.toml`, `.adapt-pending.md`, and `.upstream.<ext>`
587
+ companions at user scope; pinning the location here keeps every
588
+ caller agreeing on the layout.
589
+
590
+ Creates the dot-directory with mode `0o700` if it does not exist. The
591
+ mode mirrors `ssh`'s `~/.ssh/` — user-readable only — because state
592
+ contains paths the CLI knows are present under the user's home, which
593
+ is sensitive enough to keep out of other accounts on shared hosts.
594
+ Existing directories are left alone (no chmod) so adopters who chose
595
+ a more permissive mode on purpose keep their choice.
596
+
597
+ The `home` argument exists for testing — production callers omit it
598
+ and the helper reads `~` via `pathlib.Path.home()`.
599
+
600
+ Race-safety: previously this used ``if not base.exists():`` followed
601
+ by ``mkdir(exist_ok=False)`` — a TOCTOU window where another
602
+ process could insert a hostile entry (symlink, regular file)
603
+ between the check and the create. The current shape is
604
+ ``mkdir(exist_ok=True)`` plus a symlink / regular-directory probe
605
+ via ``lstat``; an attacker who pre-creates the path as a symlink
606
+ or a non-directory file is detected at the probe rather than
607
+ silently honoured.
608
+ """
609
+ import os
610
+ import stat as _stat
611
+
612
+ base = (home if home is not None else Path.home()) / ".agentbundle"
613
+ try:
614
+ base.mkdir(parents=True, exist_ok=True, mode=0o700)
615
+ except OSError as exc:
616
+ raise OSError(
617
+ f"cannot create user-scope state directory {base}: {exc}"
618
+ ) from exc
619
+ # Refuse a pre-existing entry that is not a regular directory
620
+ # (e.g. a symlink to an attacker-controlled location, or a stray
621
+ # file). Existing real directories are honoured even if their mode
622
+ # is more permissive than 0o700 — the doc-comment promises not to
623
+ # chmod existing dirs.
624
+ try:
625
+ st = os.lstat(base)
626
+ except OSError as exc:
627
+ raise OSError(f"cannot stat user-scope state directory {base}: {exc}") from exc
628
+ if _stat.S_ISLNK(st.st_mode) or not _stat.S_ISDIR(st.st_mode):
629
+ raise OSError(
630
+ f"user-scope state directory {base} is not a regular directory; "
631
+ f"refusing to use it"
632
+ )
633
+ return base / "state.toml"