Skill names become host/guest path segments interpolated into the
`bottle.exec` shell strings in each contrib provider's provision_skills.
They were validated only as strings, so a name with shell metacharacters
or path traversal could reach the command.
Layer two defenses:
- Primary: reject any skill name that isn't kebab-case
([a-z][a-z0-9-]*) at manifest load, reusing the convention already
enforced on bottle/agent filenames (new is_valid_entity_name helper
in manifest_schema). Fails loud and early, protecting every consumer
of the name — not just the exec call sites.
- Failsafe: shlex.quote the interpolated skills_dir / dst paths in the
claude, codex, and pi providers, so a future unvalidated field can't
inject shell metacharacters even if it bypasses the load-time check.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NkwFXLFff9PYPy4wgVBJp9
BottleSpec.manifest was ManifestIndex | Manifest — a union encoding
two lifecycle stages in one field. The union was unjustifiable:
it forced a type-narrowing workaround (loaded_manifest property)
on every consumer.
Clean split:
- BottleSpec.manifest: ManifestIndex (always; CLI-supplied intent)
- BottlePlan.manifest: Manifest (always; loaded by _validate())
_validate() returns the loaded Manifest directly. prepare() passes
it to _resolve_plan(), which stores it on the plan. All provisioner
code now reads plan.manifest.agent / plan.manifest.bottle — no
union, no asserts, no type: ignore.
BottleSpec.manifest is ManifestIndex | Manifest (pre/post _validate()).
Downstream code always runs post-validate so it needs Manifest, but
pyright flagged every .agent/.bottle access. The new loaded_manifest
property asserts isinstance and returns Manifest, giving pyright a
narrowed type without scattering type: ignore everywhere.
Also remove unused Manifest imports from test files and annotate the
_index() helper in test_manifest_agent_git_user.
Manifest now holds exactly one agent and one effective bottle (with
git_user overlay already applied). The old multi-agent/bottle
collection is renamed ManifestIndex. BottleSpec.manifest starts as
ManifestIndex from the CLI and becomes Manifest after _validate()
calls load_for_agent(); all provisioning code downstream reads
spec.manifest.agent / spec.manifest.bottle instead of indexing by name.