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,398 @@
|
|
|
1
|
+
"""T12: user-scope refusal rails — seeds / hooks / marker.
|
|
2
|
+
|
|
3
|
+
Verifies AC #16 (RFC-0004) for the distribution-adapters spec. The rails
|
|
4
|
+
fire only when a pack declares `"user" ∈ allowed-scopes` — repo-only
|
|
5
|
+
packs are not inspected, so SKILL.md files that *document* the marker
|
|
6
|
+
syntax (e.g. `adapt-to-project`) are not refused because their packs
|
|
7
|
+
declare `allowed-scopes = ["repo"]`.
|
|
8
|
+
|
|
9
|
+
Each test builds its fixture in a `tempfile.TemporaryDirectory()` to
|
|
10
|
+
keep the build/ fixtures tree small and to make the marker-byte tests
|
|
11
|
+
explicit about what's on disk.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import tempfile
|
|
17
|
+
import textwrap
|
|
18
|
+
import unittest
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
PACK_TOML_USER_OK = """
|
|
23
|
+
[pack]
|
|
24
|
+
name = "demo-user"
|
|
25
|
+
version = "0.1.0"
|
|
26
|
+
|
|
27
|
+
[pack.adapter-contract]
|
|
28
|
+
version = "0.2"
|
|
29
|
+
|
|
30
|
+
[pack.install]
|
|
31
|
+
default-scope = "user"
|
|
32
|
+
allowed-scopes = ["user"]
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
PACK_TOML_REPO_ONLY = """
|
|
36
|
+
[pack]
|
|
37
|
+
name = "demo-repo"
|
|
38
|
+
version = "0.1.0"
|
|
39
|
+
|
|
40
|
+
[pack.adapter-contract]
|
|
41
|
+
version = "0.2"
|
|
42
|
+
|
|
43
|
+
[pack.install]
|
|
44
|
+
default-scope = "repo"
|
|
45
|
+
allowed-scopes = ["repo"]
|
|
46
|
+
"""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def _write_pack(root: Path, name: str, toml_text: str) -> Path:
|
|
50
|
+
pack = root / name
|
|
51
|
+
pack.mkdir()
|
|
52
|
+
(pack / "pack.toml").write_text(toml_text, encoding="utf-8")
|
|
53
|
+
return pack
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
class RailASeedsTests(unittest.TestCase):
|
|
57
|
+
"""A non-empty seeds/ with allowed-scopes=['user'] is refused."""
|
|
58
|
+
|
|
59
|
+
def test_rail_a_refuses_seeds_with_user_scope(self) -> None:
|
|
60
|
+
from agentbundle.build.scope_rails import check_seeds
|
|
61
|
+
|
|
62
|
+
with tempfile.TemporaryDirectory() as td:
|
|
63
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
64
|
+
(pack / "seeds").mkdir()
|
|
65
|
+
(pack / "seeds" / "AGENTS.md").write_text("hi", encoding="utf-8")
|
|
66
|
+
|
|
67
|
+
result = check_seeds(pack, ["user"])
|
|
68
|
+
self.assertIsNotNone(result)
|
|
69
|
+
self.assertIn("seeds/AGENTS.md", result)
|
|
70
|
+
|
|
71
|
+
def test_rail_a_accepts_seeds_with_repo_only(self) -> None:
|
|
72
|
+
from agentbundle.build.scope_rails import check_seeds
|
|
73
|
+
|
|
74
|
+
with tempfile.TemporaryDirectory() as td:
|
|
75
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_REPO_ONLY)
|
|
76
|
+
(pack / "seeds").mkdir()
|
|
77
|
+
(pack / "seeds" / "AGENTS.md").write_text("hi", encoding="utf-8")
|
|
78
|
+
|
|
79
|
+
self.assertIsNone(check_seeds(pack, ["repo"]))
|
|
80
|
+
|
|
81
|
+
def test_rail_a_accepts_empty_seeds(self) -> None:
|
|
82
|
+
from agentbundle.build.scope_rails import check_seeds
|
|
83
|
+
|
|
84
|
+
with tempfile.TemporaryDirectory() as td:
|
|
85
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
86
|
+
(pack / "seeds").mkdir()
|
|
87
|
+
self.assertIsNone(check_seeds(pack, ["user"]))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class RailBHooksTests(unittest.TestCase):
|
|
91
|
+
"""A non-empty .apm/hooks/ or .apm/hook-wiring/ with user scope is refused."""
|
|
92
|
+
|
|
93
|
+
def test_rail_b_refuses_hook_body_with_user_scope(self) -> None:
|
|
94
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
95
|
+
|
|
96
|
+
with tempfile.TemporaryDirectory() as td:
|
|
97
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
98
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
99
|
+
(pack / ".apm" / "hooks" / "pre-pr.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
100
|
+
|
|
101
|
+
result = check_hooks(pack, ["user"])
|
|
102
|
+
self.assertIsNotNone(result)
|
|
103
|
+
self.assertIn(".apm/hooks/pre-pr.sh", result)
|
|
104
|
+
|
|
105
|
+
def test_rail_b_refuses_hook_wiring_with_user_scope(self) -> None:
|
|
106
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
107
|
+
|
|
108
|
+
with tempfile.TemporaryDirectory() as td:
|
|
109
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
110
|
+
(pack / ".apm" / "hook-wiring").mkdir(parents=True)
|
|
111
|
+
(pack / ".apm" / "hook-wiring" / "pre-pr.toml").write_text("[hooks]\n", encoding="utf-8")
|
|
112
|
+
|
|
113
|
+
result = check_hooks(pack, ["user"])
|
|
114
|
+
self.assertIsNotNone(result)
|
|
115
|
+
self.assertIn(".apm/hook-wiring/pre-pr.toml", result)
|
|
116
|
+
|
|
117
|
+
def test_rail_b_accepts_hooks_with_repo_only(self) -> None:
|
|
118
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
119
|
+
|
|
120
|
+
with tempfile.TemporaryDirectory() as td:
|
|
121
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_REPO_ONLY)
|
|
122
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
123
|
+
(pack / ".apm" / "hooks" / "pre-pr.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
124
|
+
self.assertIsNone(check_hooks(pack, ["repo"]))
|
|
125
|
+
|
|
126
|
+
def test_rail_b_accepts_no_hooks(self) -> None:
|
|
127
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
128
|
+
|
|
129
|
+
with tempfile.TemporaryDirectory() as td:
|
|
130
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
131
|
+
self.assertIsNone(check_hooks(pack, ["user"]))
|
|
132
|
+
|
|
133
|
+
def test_rail_b_lifts_when_user_scope_hooks_true(self) -> None:
|
|
134
|
+
"""RFC-0005 § Rail B — user-scope lift: a pack that opts in via
|
|
135
|
+
``user-scope-hooks = true`` is accepted even with hooks at user
|
|
136
|
+
scope. The flag is the consent gesture."""
|
|
137
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
138
|
+
|
|
139
|
+
with tempfile.TemporaryDirectory() as td:
|
|
140
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
141
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
142
|
+
(pack / ".apm" / "hooks" / "pre-pr.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
143
|
+
self.assertIsNone(
|
|
144
|
+
check_hooks(pack, ["user"], user_scope_hooks=True),
|
|
145
|
+
"Rail B did not lift on user_scope_hooks=True",
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
def test_rail_b_refuses_without_user_scope_hooks_default(self) -> None:
|
|
149
|
+
"""The default (user_scope_hooks=False) preserves the v0.2 refusal
|
|
150
|
+
behaviour — a pack with hooks at user scope is refused."""
|
|
151
|
+
from agentbundle.build.scope_rails import check_hooks
|
|
152
|
+
|
|
153
|
+
with tempfile.TemporaryDirectory() as td:
|
|
154
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
155
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
156
|
+
(pack / ".apm" / "hooks" / "pre-pr.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
|
|
157
|
+
# Default (no flag passed) — must refuse.
|
|
158
|
+
self.assertIsNotNone(check_hooks(pack, ["user"]))
|
|
159
|
+
# Explicit False — same refusal.
|
|
160
|
+
self.assertIsNotNone(check_hooks(pack, ["user"], user_scope_hooks=False))
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class RailCMarkersTests(unittest.TestCase):
|
|
164
|
+
"""`<adapt:NAME>` markers under .apm/skills/, /agents/, /commands/ refused."""
|
|
165
|
+
|
|
166
|
+
def test_rail_c_refuses_upper_snake_marker_in_skill_with_user_scope(self) -> None:
|
|
167
|
+
"""Legacy UPPER_SNAKE form `<adapt:NAME>` is refused."""
|
|
168
|
+
from agentbundle.build.scope_rails import check_markers
|
|
169
|
+
|
|
170
|
+
with tempfile.TemporaryDirectory() as td:
|
|
171
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
172
|
+
skills_dir = pack / ".apm" / "skills" / "my-skill"
|
|
173
|
+
skills_dir.mkdir(parents=True)
|
|
174
|
+
(skills_dir / "SKILL.md").write_text(
|
|
175
|
+
"# My skill\n\nDoes <adapt:PROJECT_NAME> things.\n",
|
|
176
|
+
encoding="utf-8",
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
result = check_markers(pack, ["user"])
|
|
180
|
+
self.assertIsNotNone(result)
|
|
181
|
+
self.assertIn(".apm/skills/my-skill/SKILL.md", result)
|
|
182
|
+
|
|
183
|
+
def test_rail_c_refuses_lowercase_hyphen_marker_in_skill_with_user_scope(self) -> None:
|
|
184
|
+
"""Canonical lowercase-hyphen form `<adapt:project-name>` is refused.
|
|
185
|
+
|
|
186
|
+
Closes the AC21 carve-out: until the code-side widening, a
|
|
187
|
+
user-scope pack carrying the canonical marker form passed
|
|
188
|
+
`validate` in code even though the spec contract refused it.
|
|
189
|
+
"""
|
|
190
|
+
from agentbundle.build.scope_rails import check_markers
|
|
191
|
+
|
|
192
|
+
with tempfile.TemporaryDirectory() as td:
|
|
193
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
194
|
+
skills_dir = pack / ".apm" / "skills" / "my-skill"
|
|
195
|
+
skills_dir.mkdir(parents=True)
|
|
196
|
+
(skills_dir / "SKILL.md").write_text(
|
|
197
|
+
"# My skill\n\nDoes <adapt:project-name> things.\n",
|
|
198
|
+
encoding="utf-8",
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
result = check_markers(pack, ["user"])
|
|
202
|
+
self.assertIsNotNone(result)
|
|
203
|
+
self.assertIn(".apm/skills/my-skill/SKILL.md", result)
|
|
204
|
+
|
|
205
|
+
def test_rail_c_refuses_lowercase_marker_in_agent_with_user_scope(self) -> None:
|
|
206
|
+
"""Canonical form is refused under `.apm/agents/` too (rail directory coverage)."""
|
|
207
|
+
from agentbundle.build.scope_rails import check_markers
|
|
208
|
+
|
|
209
|
+
with tempfile.TemporaryDirectory() as td:
|
|
210
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
211
|
+
agents_dir = pack / ".apm" / "agents"
|
|
212
|
+
agents_dir.mkdir(parents=True)
|
|
213
|
+
(agents_dir / "reviewer.md").write_text("Owner: <adapt:owner>\n", encoding="utf-8")
|
|
214
|
+
|
|
215
|
+
result = check_markers(pack, ["user"])
|
|
216
|
+
self.assertIsNotNone(result)
|
|
217
|
+
self.assertIn(".apm/agents/reviewer.md", result)
|
|
218
|
+
|
|
219
|
+
def test_rail_c_accepts_non_marker_strings(self) -> None:
|
|
220
|
+
"""Strings that resemble markers but don't match either grammar pass.
|
|
221
|
+
|
|
222
|
+
Wrong-cased prefix `<ADAPT:NAME>` and mixed-case names like
|
|
223
|
+
`<adapt:MixedCase>` are not valid markers under either casing
|
|
224
|
+
and must not trigger the rail.
|
|
225
|
+
"""
|
|
226
|
+
from agentbundle.build.scope_rails import check_markers
|
|
227
|
+
|
|
228
|
+
with tempfile.TemporaryDirectory() as td:
|
|
229
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
230
|
+
skills_dir = pack / ".apm" / "skills" / "non-markers"
|
|
231
|
+
skills_dir.mkdir(parents=True)
|
|
232
|
+
# Wrong-cased prefix — must not match either grammar.
|
|
233
|
+
(skills_dir / "A.md").write_text("plays with <ADAPT:NAME>", encoding="utf-8")
|
|
234
|
+
# Mixed-case name — matches neither UPPER_SNAKE nor lowercase-hyphen.
|
|
235
|
+
(skills_dir / "B.md").write_text("plays with <adapt:MixedCase>", encoding="utf-8")
|
|
236
|
+
# Empty name — matches neither grammar.
|
|
237
|
+
(skills_dir / "C.md").write_text("plays with <adapt:>", encoding="utf-8")
|
|
238
|
+
self.assertIsNone(check_markers(pack, ["user"]))
|
|
239
|
+
|
|
240
|
+
def test_rail_c_does_not_inspect_repo_only_pack(self) -> None:
|
|
241
|
+
"""Repo-only packs are not inspected — the rail's scope clause stops it."""
|
|
242
|
+
from agentbundle.build.scope_rails import check_markers
|
|
243
|
+
|
|
244
|
+
with tempfile.TemporaryDirectory() as td:
|
|
245
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_REPO_ONLY)
|
|
246
|
+
skills_dir = pack / ".apm" / "skills" / "doc-marker"
|
|
247
|
+
skills_dir.mkdir(parents=True)
|
|
248
|
+
(skills_dir / "SKILL.md").write_text("documents <adapt:NAME>", encoding="utf-8")
|
|
249
|
+
self.assertIsNone(check_markers(pack, ["repo"]))
|
|
250
|
+
|
|
251
|
+
def test_rail_c_skips_binary_files(self) -> None:
|
|
252
|
+
"""Non-UTF-8 files are skipped silently."""
|
|
253
|
+
from agentbundle.build.scope_rails import check_markers
|
|
254
|
+
|
|
255
|
+
with tempfile.TemporaryDirectory() as td:
|
|
256
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
257
|
+
skills_dir = pack / ".apm" / "skills" / "with-binary"
|
|
258
|
+
skills_dir.mkdir(parents=True)
|
|
259
|
+
# Raw bytes that are not valid UTF-8 — should be skipped.
|
|
260
|
+
(skills_dir / "icon.bin").write_bytes(b"\xff\xfe\x00<adapt:NAME>")
|
|
261
|
+
# A clean text file in the same directory.
|
|
262
|
+
(skills_dir / "SKILL.md").write_text("# clean\n", encoding="utf-8")
|
|
263
|
+
self.assertIsNone(check_markers(pack, ["user"]))
|
|
264
|
+
|
|
265
|
+
def test_rail_c_refuses_symlink_under_skills(self) -> None:
|
|
266
|
+
"""RFC-0004 Rail C must refuse symlinks under primitive dirs.
|
|
267
|
+
|
|
268
|
+
A `*.md → /dev/zero` symlink would bypass the size cap because
|
|
269
|
+
`stat()` follows the symlink and reports the target's
|
|
270
|
+
size (zero for /dev/zero). The lstat-based detection refuses
|
|
271
|
+
symlinks outright so the cap holds.
|
|
272
|
+
"""
|
|
273
|
+
from agentbundle.build.scope_rails import check_markers
|
|
274
|
+
import os as _os
|
|
275
|
+
|
|
276
|
+
with tempfile.TemporaryDirectory() as td:
|
|
277
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
278
|
+
skills_dir = pack / ".apm" / "skills" / "symlink-skill"
|
|
279
|
+
skills_dir.mkdir(parents=True)
|
|
280
|
+
# Create a symlink — target need not exist; the rail
|
|
281
|
+
# refuses on the symlink type alone.
|
|
282
|
+
# Test-only symlink: the Windows-portability lint forbids
|
|
283
|
+
# symlinks in shipped packs; this fixture is a runtime
|
|
284
|
+
# hostile-pack simulation, not a release artefact.
|
|
285
|
+
_os.symlink("/dev/null", skills_dir / "SKILL.md")
|
|
286
|
+
result = check_markers(pack, ["user"])
|
|
287
|
+
self.assertIsNotNone(result)
|
|
288
|
+
self.assertIn("symlink", result)
|
|
289
|
+
|
|
290
|
+
def test_rail_c_refuses_oversize_file(self) -> None:
|
|
291
|
+
"""Files larger than the size cap are refused before being read."""
|
|
292
|
+
from agentbundle.build.scope_rails import check_markers, _MARKER_RAIL_FILE_CAP_BYTES
|
|
293
|
+
|
|
294
|
+
with tempfile.TemporaryDirectory() as td:
|
|
295
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
296
|
+
skills_dir = pack / ".apm" / "skills" / "oversize"
|
|
297
|
+
skills_dir.mkdir(parents=True)
|
|
298
|
+
# Sparse file slightly larger than the cap — no marker payload needed.
|
|
299
|
+
big = skills_dir / "SKILL.md"
|
|
300
|
+
with open(big, "wb") as fh:
|
|
301
|
+
fh.seek(_MARKER_RAIL_FILE_CAP_BYTES + 1)
|
|
302
|
+
fh.write(b"\0")
|
|
303
|
+
result = check_markers(pack, ["user"])
|
|
304
|
+
self.assertIsNotNone(result)
|
|
305
|
+
self.assertIn("size cap", result)
|
|
306
|
+
|
|
307
|
+
def test_rail_c_deterministic_first_offender(self) -> None:
|
|
308
|
+
"""sorted(os.walk(...)) order — `a/` comes before `b/`."""
|
|
309
|
+
from agentbundle.build.scope_rails import check_markers
|
|
310
|
+
|
|
311
|
+
with tempfile.TemporaryDirectory() as td:
|
|
312
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
313
|
+
for sub in ("z-late", "a-early"):
|
|
314
|
+
d = pack / ".apm" / "skills" / sub
|
|
315
|
+
d.mkdir(parents=True)
|
|
316
|
+
(d / "SKILL.md").write_text("hi <adapt:NAME>", encoding="utf-8")
|
|
317
|
+
result = check_markers(pack, ["user"])
|
|
318
|
+
self.assertIsNotNone(result)
|
|
319
|
+
self.assertIn("a-early/SKILL.md", result)
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
class CliValidateSpecNamedStderrTests(unittest.TestCase):
|
|
323
|
+
"""`validate` emits the spec-named text on the cross-field invariant."""
|
|
324
|
+
|
|
325
|
+
def test_default_scope_not_in_allowed_scopes_emits_spec_text(self) -> None:
|
|
326
|
+
import argparse
|
|
327
|
+
import io
|
|
328
|
+
import contextlib
|
|
329
|
+
|
|
330
|
+
from agentbundle.commands import validate as validate_cmd
|
|
331
|
+
|
|
332
|
+
with tempfile.TemporaryDirectory() as td:
|
|
333
|
+
pack = Path(td) / "p"
|
|
334
|
+
pack.mkdir()
|
|
335
|
+
(pack / "pack.toml").write_text(
|
|
336
|
+
"""
|
|
337
|
+
[pack]
|
|
338
|
+
name = "demo-invariant"
|
|
339
|
+
version = "0.1.0"
|
|
340
|
+
|
|
341
|
+
[pack.adapter-contract]
|
|
342
|
+
version = "0.2"
|
|
343
|
+
|
|
344
|
+
[pack.install]
|
|
345
|
+
default-scope = "user"
|
|
346
|
+
allowed-scopes = ["repo"]
|
|
347
|
+
""",
|
|
348
|
+
encoding="utf-8",
|
|
349
|
+
)
|
|
350
|
+
args = argparse.Namespace(pack_path=str(pack), strict=False)
|
|
351
|
+
buf = io.StringIO()
|
|
352
|
+
with contextlib.redirect_stderr(buf):
|
|
353
|
+
rc = validate_cmd.run(args)
|
|
354
|
+
self.assertEqual(rc, 1)
|
|
355
|
+
err = buf.getvalue()
|
|
356
|
+
# Spec contract text (RFC-0004 last AC for agent-spec-cli).
|
|
357
|
+
self.assertIn("default-scope", err)
|
|
358
|
+
self.assertIn("'user'", err)
|
|
359
|
+
self.assertIn("allowed-scopes", err)
|
|
360
|
+
self.assertIn("demo-invariant", err)
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
class CliValidateWiringTests(unittest.TestCase):
|
|
364
|
+
"""The CLI's `validate` subcommand surfaces rail refusals to stderr."""
|
|
365
|
+
|
|
366
|
+
def test_validate_refuses_user_scope_pack_with_hooks(self) -> None:
|
|
367
|
+
import argparse
|
|
368
|
+
import io
|
|
369
|
+
import contextlib
|
|
370
|
+
|
|
371
|
+
from agentbundle.commands import validate as validate_cmd
|
|
372
|
+
|
|
373
|
+
with tempfile.TemporaryDirectory() as td:
|
|
374
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
375
|
+
(pack / ".apm" / "hooks").mkdir(parents=True)
|
|
376
|
+
(pack / ".apm" / "hooks" / "pre-pr.sh").write_text("#!/bin/sh\n", encoding="utf-8")
|
|
377
|
+
|
|
378
|
+
args = argparse.Namespace(pack_path=str(pack), strict=False)
|
|
379
|
+
buf = io.StringIO()
|
|
380
|
+
with contextlib.redirect_stderr(buf):
|
|
381
|
+
rc = validate_cmd.run(args)
|
|
382
|
+
self.assertEqual(rc, 1)
|
|
383
|
+
self.assertIn("demo-user", buf.getvalue())
|
|
384
|
+
self.assertIn(".apm/hooks/pre-pr.sh", buf.getvalue())
|
|
385
|
+
|
|
386
|
+
def test_validate_accepts_user_scope_pack_with_no_offenders(self) -> None:
|
|
387
|
+
import argparse
|
|
388
|
+
|
|
389
|
+
from agentbundle.commands import validate as validate_cmd
|
|
390
|
+
|
|
391
|
+
with tempfile.TemporaryDirectory() as td:
|
|
392
|
+
pack = _write_pack(Path(td), "p", PACK_TOML_USER_OK)
|
|
393
|
+
args = argparse.Namespace(pack_path=str(pack), strict=False)
|
|
394
|
+
self.assertEqual(validate_cmd.run(args), 0)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
if __name__ == "__main__":
|
|
398
|
+
unittest.main()
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Security-lens tests added in the post-EXECUTE fix-pass.
|
|
2
|
+
|
|
3
|
+
These are defense-in-depth — none of these failure modes are
|
|
4
|
+
exploitable today against the four repo-owned fixture packs, but
|
|
5
|
+
RFC-0001 anticipates third-party pack submission and these tests
|
|
6
|
+
cover the bundle's attack surface against pack-supplied content.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import tempfile
|
|
13
|
+
import unittest
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from agentbundle.build.adapters.claude_code import project as project_claude_code
|
|
17
|
+
from agentbundle.build.contract import load as load_contract
|
|
18
|
+
from agentbundle.build.main import (
|
|
19
|
+
_assert_under,
|
|
20
|
+
validate_plugin_manifest,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
REPO_ROOT = Path(__file__).resolve().parents[5]
|
|
24
|
+
CONTRACT_PATH = REPO_ROOT / "docs" / "contracts" / "adapter.toml"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class PathTraversalGuardTests(unittest.TestCase):
|
|
28
|
+
def test_assert_under_accepts_path_inside_base(self) -> None:
|
|
29
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
30
|
+
base = Path(tmp)
|
|
31
|
+
(base / "subdir").mkdir()
|
|
32
|
+
_assert_under(base / "subdir", base) # no raise
|
|
33
|
+
|
|
34
|
+
def test_assert_under_rejects_escape(self) -> None:
|
|
35
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
36
|
+
base = Path(tmp) / "inside"
|
|
37
|
+
base.mkdir()
|
|
38
|
+
with self.assertRaises(ValueError) as caught:
|
|
39
|
+
_assert_under(base / ".." / ".." / "etc", base)
|
|
40
|
+
self.assertIn("outside output root", str(caught.exception))
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class SymlinkProjectionTests(unittest.TestCase):
|
|
44
|
+
@classmethod
|
|
45
|
+
def setUpClass(cls) -> None:
|
|
46
|
+
cls.contract = load_contract(CONTRACT_PATH)
|
|
47
|
+
|
|
48
|
+
def test_symlink_in_pack_skill_is_preserved_not_dereferenced(self) -> None:
|
|
49
|
+
"""A pack with a symlink to /etc/passwd should not exfiltrate
|
|
50
|
+
the target into the projection — symlinks=True preserves them
|
|
51
|
+
as symlinks rather than copying the target's contents.
|
|
52
|
+
|
|
53
|
+
Test-only symlink creation: Windows-portability lint
|
|
54
|
+
(`lint_packs.py`) rejects symlinks in shipped pack content, so
|
|
55
|
+
this construction is purely a runtime hostile-pack simulation
|
|
56
|
+
and does not contradict the no-symlinks rule for releases."""
|
|
57
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
58
|
+
tmp_path = Path(tmp)
|
|
59
|
+
pack = tmp_path / "pack"
|
|
60
|
+
skill = pack / ".apm" / "skills" / "foo"
|
|
61
|
+
skill.mkdir(parents=True)
|
|
62
|
+
(skill / "SKILL.md").write_text("ok\n", encoding="utf-8")
|
|
63
|
+
evil = skill / "leak.txt"
|
|
64
|
+
os.symlink("/etc/passwd", evil)
|
|
65
|
+
|
|
66
|
+
out = tmp_path / "out"
|
|
67
|
+
project_claude_code(pack, self.contract, out)
|
|
68
|
+
projected = out / ".claude" / "skills" / "foo" / "leak.txt"
|
|
69
|
+
self.assertTrue(projected.is_symlink())
|
|
70
|
+
# The link target is preserved as a symlink, not dereferenced.
|
|
71
|
+
self.assertEqual(os.readlink(projected), "/etc/passwd")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class PluginManifestValidationTests(unittest.TestCase):
|
|
75
|
+
def test_minimal_manifest_passes(self) -> None:
|
|
76
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
77
|
+
path = Path(tmp) / "plugin.json"
|
|
78
|
+
path.write_text(
|
|
79
|
+
'{"name": "x", "version": "0.1.0", "description": "d"}',
|
|
80
|
+
encoding="utf-8",
|
|
81
|
+
)
|
|
82
|
+
validate_plugin_manifest(path) # no raise
|
|
83
|
+
|
|
84
|
+
def test_missing_name_rejected(self) -> None:
|
|
85
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
86
|
+
path = Path(tmp) / "plugin.json"
|
|
87
|
+
path.write_text(
|
|
88
|
+
'{"version": "0.1.0", "description": "d"}',
|
|
89
|
+
encoding="utf-8",
|
|
90
|
+
)
|
|
91
|
+
with self.assertRaises(ValueError) as caught:
|
|
92
|
+
validate_plugin_manifest(path)
|
|
93
|
+
self.assertIn("failed schema", str(caught.exception))
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
if __name__ == "__main__":
|
|
97
|
+
unittest.main()
|