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
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"
|