agentbundle 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (99) hide show
  1. agentbundle/__init__.py +14 -0
  2. agentbundle/__main__.py +5 -0
  3. agentbundle/_data/adapter.schema.json +270 -0
  4. agentbundle/_data/adapter.toml +584 -0
  5. agentbundle/_data/install-marker.py +1099 -0
  6. agentbundle/_data/pack.schema.json +152 -0
  7. agentbundle/_data/plugin-manifest.derived.schema.json +33 -0
  8. agentbundle/_data/plugin-manifest.schema.json +18 -0
  9. agentbundle/build/__init__.py +206 -0
  10. agentbundle/build/__main__.py +8 -0
  11. agentbundle/build/adapter_root_bins.py +336 -0
  12. agentbundle/build/adapters/__init__.py +46 -0
  13. agentbundle/build/adapters/claude_code.py +142 -0
  14. agentbundle/build/adapters/codex.py +227 -0
  15. agentbundle/build/adapters/copilot.py +149 -0
  16. agentbundle/build/adapters/kiro.py +608 -0
  17. agentbundle/build/adapters/kiro_cli.py +53 -0
  18. agentbundle/build/adapters/kiro_ide.py +275 -0
  19. agentbundle/build/contract.py +20 -0
  20. agentbundle/build/lint_packs.py +555 -0
  21. agentbundle/build/main.py +596 -0
  22. agentbundle/build/phase_order.py +40 -0
  23. agentbundle/build/projections/__init__.py +13 -0
  24. agentbundle/build/projections/codex_agent_toml.py +232 -0
  25. agentbundle/build/projections/copilot_agent_md.py +206 -0
  26. agentbundle/build/projections/copilot_hooks_json.py +142 -0
  27. agentbundle/build/projections/direct_directory.py +41 -0
  28. agentbundle/build/projections/hook_id.py +27 -0
  29. agentbundle/build/projections/kiro_ide_hook.py +256 -0
  30. agentbundle/build/projections/merge_into_agent_json.py +264 -0
  31. agentbundle/build/projections/merge_json.py +58 -0
  32. agentbundle/build/projections/user_merge_json.py +324 -0
  33. agentbundle/build/scope_rails.py +728 -0
  34. agentbundle/build/self_host.py +1486 -0
  35. agentbundle/build/shared_libs.py +309 -0
  36. agentbundle/build/target_resolver.py +85 -0
  37. agentbundle/build/tests/__init__.py +0 -0
  38. agentbundle/build/tests/test_adapter_claude_code.py +275 -0
  39. agentbundle/build/tests/test_adapter_codex.py +699 -0
  40. agentbundle/build/tests/test_adapter_copilot.py +91 -0
  41. agentbundle/build/tests/test_adapter_kiro.py +449 -0
  42. agentbundle/build/tests/test_adapter_kiro_alias.py +105 -0
  43. agentbundle/build/tests/test_adapter_kiro_cli.py +102 -0
  44. agentbundle/build/tests/test_adapter_kiro_ide.py +173 -0
  45. agentbundle/build/tests/test_adapter_root_bins_projection.py +429 -0
  46. agentbundle/build/tests/test_build_ships_seeds.py +78 -0
  47. agentbundle/build/tests/test_contract.py +582 -0
  48. agentbundle/build/tests/test_contract_scope.py +224 -0
  49. agentbundle/build/tests/test_contract_v07.py +191 -0
  50. agentbundle/build/tests/test_contract_v08.py +230 -0
  51. agentbundle/build/tests/test_direct_directory_cleanup.py +65 -0
  52. agentbundle/build/tests/test_end_to_end_build.py +227 -0
  53. agentbundle/build/tests/test_lint_agents_md_legacy_block.py +135 -0
  54. agentbundle/build/tests/test_lint_agents_md_risk_block.py +116 -0
  55. agentbundle/build/tests/test_lint_packs.py +703 -0
  56. agentbundle/build/tests/test_load_pack_hook_wiring_safely.py +176 -0
  57. agentbundle/build/tests/test_pack_schema.py +265 -0
  58. agentbundle/build/tests/test_pack_schema_allowed_adapters.py +258 -0
  59. agentbundle/build/tests/test_pack_schema_install.py +305 -0
  60. agentbundle/build/tests/test_pipeline.py +272 -0
  61. agentbundle/build/tests/test_plugin_manifest_schema.py +327 -0
  62. agentbundle/build/tests/test_projections_merge_json.py +148 -0
  63. agentbundle/build/tests/test_scope_rails.py +398 -0
  64. agentbundle/build/tests/test_security.py +97 -0
  65. agentbundle/build/tests/test_self_host_check.py +2100 -0
  66. agentbundle/build/tests/test_shared_libs_projection.py +415 -0
  67. agentbundle/build/tests/test_shipped_packs_v07_declarations.py +100 -0
  68. agentbundle/build/tests/test_shipped_packs_v08_declarations.py +80 -0
  69. agentbundle/build/tests/test_validate.py +250 -0
  70. agentbundle/build/validate.py +141 -0
  71. agentbundle/catalogue.py +164 -0
  72. agentbundle/cli.py +486 -0
  73. agentbundle/commands/__init__.py +5 -0
  74. agentbundle/commands/_common.py +174 -0
  75. agentbundle/commands/_drop_warning.py +329 -0
  76. agentbundle/commands/adapt.py +343 -0
  77. agentbundle/commands/config.py +125 -0
  78. agentbundle/commands/diff.py +211 -0
  79. agentbundle/commands/init_state.py +279 -0
  80. agentbundle/commands/install.py +3026 -0
  81. agentbundle/commands/list_packs.py +170 -0
  82. agentbundle/commands/list_targets.py +23 -0
  83. agentbundle/commands/reconcile.py +161 -0
  84. agentbundle/commands/render.py +165 -0
  85. agentbundle/commands/scaffold.py +69 -0
  86. agentbundle/commands/uninstall.py +294 -0
  87. agentbundle/commands/upgrade.py +699 -0
  88. agentbundle/commands/validate.py +688 -0
  89. agentbundle/config.py +747 -0
  90. agentbundle/render.py +123 -0
  91. agentbundle/safety.py +633 -0
  92. agentbundle/scope.py +319 -0
  93. agentbundle/user_config.py +284 -0
  94. agentbundle/version.py +49 -0
  95. agentbundle-0.2.0.dist-info/METADATA +37 -0
  96. agentbundle-0.2.0.dist-info/RECORD +99 -0
  97. agentbundle-0.2.0.dist-info/WHEEL +5 -0
  98. agentbundle-0.2.0.dist-info/entry_points.txt +2 -0
  99. agentbundle-0.2.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,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()