diff --git a/bot_bottle/manifest/__init__.py b/bot_bottle/manifest/__init__.py index 1dde0285..faef230e 100644 --- a/bot_bottle/manifest/__init__.py +++ b/bot_bottle/manifest/__init__.py @@ -66,7 +66,12 @@ if TYPE_CHECKING: from .agent import ManifestAgent, ManifestAgentProvider from .bottle import ManifestBottle from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute - from .git import ManifestGitEntry, ManifestGitUser, ManifestKeyConfig + from .git import ( + ManifestGitEntry, + ManifestGitSigning, + ManifestGitUser, + ManifestKeyConfig, + ) # Facade name -> submodule that defines it. The aggregate model (`Manifest`, @@ -82,6 +87,7 @@ _LAZY_MODULES: dict[str, str] = { "ManifestEgressRoute": "egress", "ManifestEgressConfig": "egress", "ManifestGitEntry": "git", + "ManifestGitSigning": "git", "ManifestGitUser": "git", "ManifestKeyConfig": "git", } @@ -107,6 +113,7 @@ __all__ = [ "ManifestIndex", "ManifestError", "ManifestGitEntry", + "ManifestGitSigning", "ManifestGitUser", "ManifestKeyConfig", "ManifestAgentProvider", diff --git a/bot_bottle/manifest/bottle.py b/bot_bottle/manifest/bottle.py index ae76d7e3..aaea9171 100644 --- a/bot_bottle/manifest/bottle.py +++ b/bot_bottle/manifest/bottle.py @@ -18,7 +18,12 @@ from typing import Mapping from .util import ManifestError, as_json_object from .agent import ManifestAgentProvider from .egress import ManifestEgressConfig -from .git import ManifestGitEntry, ManifestGitUser, parse_git_gate_config +from .git import ( + ManifestGitEntry, + ManifestGitSigning, + ManifestGitUser, + parse_git_gate_config, +) from .schema import BOTTLE_KEYS __all__ = ["ManifestBottle"] @@ -38,6 +43,10 @@ class ManifestBottle: # `git config --global` step entirely. A bottle can declare a user # identity without any git-gate.repos upstreams, and vice versa. git_user: ManifestGitUser = field(default_factory=ManifestGitUser) + # Per-bottle commit signing (PRD prd-new: signed commits & audit + # attribution). Off by default; `git-gate.signing.enabled: true` + # opts a bottle into per-activation signing + audit. Bottle-only. + git_signing: ManifestGitSigning = field(default_factory=ManifestGitSigning) egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig) # Per-bottle stuck-recovery daemon (PRD 0013). When true (the # default, issue #249), the launch step brings up a supervise @@ -109,9 +118,10 @@ class ManifestBottle: git: tuple[ManifestGitEntry, ...] = () git_user = ManifestGitUser() + git_signing = ManifestGitSigning() git_raw = d.get("git-gate") if git_raw is not None: - git, git_user = parse_git_gate_config(name, git_raw) + git, git_user, git_signing = parse_git_gate_config(name, git_raw) agent_provider = ( ManifestAgentProvider.from_dict(name, d["agent_provider"]) @@ -141,7 +151,8 @@ class ManifestBottle: return cls( env=env, agent_provider=agent_provider, git=git, - git_user=git_user, egress=egress, supervise=supervise_raw, + git_user=git_user, git_signing=git_signing, egress=egress, + supervise=supervise_raw, nested_containers=nested_raw, declared_fields=frozenset(d), ) diff --git a/bot_bottle/manifest/extends.py b/bot_bottle/manifest/extends.py index c96b032c..41e4021c 100644 --- a/bot_bottle/manifest/extends.py +++ b/bot_bottle/manifest/extends.py @@ -4,7 +4,7 @@ from __future__ import annotations from .bottle import ManifestBottle from .egress import ManifestEgressConfig, validate_egress_routes -from .git import ManifestGitUser, parse_git_gate_config +from .git import ManifestGitSigning, ManifestGitUser, parse_git_gate_config from .util import ManifestError, as_json_object @@ -19,6 +19,17 @@ def _overlay_declared_bool( return value +def _overlay_signing( + base: ManifestBottle, override: ManifestBottle +) -> ManifestGitSigning: + """Overlay `git-gate.signing`: an override that enables signing wins; + otherwise the base's value is inherited. Mirrors the non-empty-wins + overlay used for `git_user` — a child cannot un-set a parent's signing + by declaring `enabled: false` (that reads as the default), the same + way a child cannot blank a parent's git-gate.user field.""" + return override.git_signing if override.git_signing.enabled else base.git_signing + + def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle": """Merge an ordered list of pre-resolved ManifestBottle objects. @@ -69,6 +80,7 @@ def _merge_two_bottles_runtime(base: "ManifestBottle", override: "ManifestBottle agent_provider=override.agent_provider, git=merged_git, git_user=merged_git_user, + git_signing=_overlay_signing(base, override), egress=merged_egress, supervise=_overlay_declared_bool(base, override, "supervise"), nested_containers=_overlay_declared_bool( @@ -210,7 +222,7 @@ def _fold_two_bottles( for n in names } if merged_repos_raw: - merged_git, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw}) + merged_git, _, _ = parse_git_gate_config("_fold", {"repos": merged_repos_raw}) else: merged_git = () @@ -225,6 +237,7 @@ def _fold_two_bottles( agent_provider=later.agent_provider, git=merged_git, git_user=merged_git_user, + git_signing=_overlay_signing(earlier, later), egress=merged_egress, supervise=_overlay_declared_bool(earlier, later, "supervise"), nested_containers=_overlay_declared_bool( @@ -299,6 +312,7 @@ def _merge_bottles( agent_provider=merged_agent_provider, git=merged_git, git_user=merged_git_user, + git_signing=_overlay_signing(parent, child), egress=merged_egress, supervise=merged_supervise, nested_containers=merged_nested_containers, diff --git a/bot_bottle/manifest/git.py b/bot_bottle/manifest/git.py index 44c87b68..632f2395 100644 --- a/bot_bottle/manifest/git.py +++ b/bot_bottle/manifest/git.py @@ -283,16 +283,52 @@ class ManifestGitUser: return not self.name and not self.email +@dataclass(frozen=True) +class ManifestGitSigning: + """Per-bottle commit-signing switch (PRD prd-new: signed commits & + audit attribution). + + When `enabled`, the launcher mints a per-activation Ed25519 signing + key host-side, holds the private half in the sidecar ssh-agent, and + configures the bottle to sign every commit at commit time; the gate + rejects any commit it forwards that is not signed by the activation + key, and the control plane records an audit binding. + + Bottle-only: it carries data-plane / audit policy, not an + agent-overlayable identity, so — like `git-gate.repos` — it is + rejected at the agent level. Defaults to off: bottles that omit + `git-gate.signing` behave exactly as before.""" + + enabled: bool = False + + @classmethod + def from_dict(cls, bottle_name: str, raw: object) -> "ManifestGitSigning": + d = as_json_object(raw, f"bottle '{bottle_name}' git-gate.signing") + for k in d: + if k != "enabled": + raise ManifestError( + f"bottle '{bottle_name}' git-gate.signing has unknown key " + f"{k!r}; allowed: enabled" + ) + enabled = d.get("enabled", False) + if not isinstance(enabled, bool): + raise ManifestError( + f"bottle '{bottle_name}' git-gate.signing.enabled must be a " + f"boolean (was {type(enabled).__name__})" + ) + return cls(enabled=enabled) + + def parse_git_gate_config( bottle_name: str, raw: object, -) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]: +) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser, ManifestGitSigning]: d = as_json_object(raw, f"bottle '{bottle_name}' git-gate") for k in d: - if k not in {"user", "repos"}: + if k not in {"user", "repos", "signing"}: raise ManifestError( f"bottle '{bottle_name}' git-gate has unknown key {k!r}; " - f"allowed: user, repos" + f"allowed: user, repos, signing" ) git_user = ( @@ -301,6 +337,12 @@ def parse_git_gate_config( else ManifestGitUser() ) + git_signing = ( + ManifestGitSigning.from_dict(bottle_name, d["signing"]) + if "signing" in d + else ManifestGitSigning() + ) + git: tuple[ManifestGitEntry, ...] = () repos_raw = d.get("repos") if repos_raw is not None: @@ -311,4 +353,4 @@ def parse_git_gate_config( ) validate_unique_git_names(bottle_name, git) - return git, git_user + return git, git_user, git_signing diff --git a/tests/unit/test_manifest_git_signing.py b/tests/unit/test_manifest_git_signing.py new file mode 100644 index 00000000..8e32617e --- /dev/null +++ b/tests/unit/test_manifest_git_signing.py @@ -0,0 +1,133 @@ +"""Unit: git-gate.signing manifest parsing + validation. + +PRD prd-new (signed commits & audit attribution): a bottle opts into +per-activation commit signing with `git-gate.signing.enabled: true`. +The block is bottle-only (rejected at the agent level, like +git-gate.repos) and defaults to off. +""" + +import unittest + +from bot_bottle.manifest import ManifestError, ManifestGitSigning, ManifestIndex + + +def _bottle(git_gate: dict) -> dict: # type: ignore + return { + "bottles": {"dev": {"git-gate": git_gate}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + } + + +class TestSigningParsing(unittest.TestCase): + def test_default_is_disabled(self): + """A bottle with no git-gate block signs nothing.""" + m = ManifestIndex.from_json_obj({ + "bottles": {"dev": {}}, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + self.assertEqual(ManifestGitSigning(), m.bottles["dev"].git_signing) + self.assertFalse(m.bottles["dev"].git_signing.enabled) + + def test_git_gate_without_signing_is_disabled(self): + """git-gate present (e.g. user only) but no signing → off.""" + m = ManifestIndex.from_json_obj(_bottle({ + "user": {"name": "claude", "email": "eric+claude@dideric.is"}, + })) + self.assertFalse(m.bottles["dev"].git_signing.enabled) + + def test_enabled_true(self): + m = ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": True}})) + self.assertTrue(m.bottles["dev"].git_signing.enabled) + + def test_enabled_false(self): + m = ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": False}})) + self.assertFalse(m.bottles["dev"].git_signing.enabled) + + def test_signing_coexists_with_user_and_repos(self): + m = ManifestIndex.from_json_obj(_bottle({ + "user": {"name": "claude", "email": "eric+claude@dideric.is"}, + "signing": {"enabled": True}, + "repos": { + "bot-bottle": { + "url": "ssh://git@gitea.dideric.is:30009/didericis/bot-bottle.git", + "key": {"provider": "static", "path": "/dev/null"}, + }, + }, + })) + b = m.bottles["dev"] + self.assertTrue(b.git_signing.enabled) + self.assertEqual("claude", b.git_user.name) + self.assertEqual(1, len(b.git)) + + +class TestSigningValidation(unittest.TestCase): + def test_unknown_key_under_signing_dies(self): + with self.assertRaises(ManifestError) as cm: + ManifestIndex.from_json_obj(_bottle({ + "signing": {"enabled": True, "enforce": ["author"]}, + })) + msg = str(cm.exception) + self.assertIn("git-gate.signing", msg) + self.assertIn("enforce", msg) + + def test_non_bool_enabled_dies(self): + with self.assertRaises(ManifestError) as cm: + ManifestIndex.from_json_obj(_bottle({"signing": {"enabled": "yes"}})) + self.assertIn("git-gate.signing.enabled must be a boolean", str(cm.exception)) + + def test_signing_not_a_mapping_dies(self): + with self.assertRaises(ManifestError): + ManifestIndex.from_json_obj(_bottle({"signing": ["enabled"]})) + + def test_unknown_git_gate_key_lists_signing(self): + """The git-gate allowed-key error names signing as valid.""" + with self.assertRaises(ManifestError) as cm: + ManifestIndex.from_json_obj(_bottle({"bogus": {}})) + self.assertIn("allowed: user, repos, signing", str(cm.exception)) + + +class TestSigningIsBottleOnly(unittest.TestCase): + def test_agent_level_signing_rejected(self): + """git-gate.signing on an agent dies — it is bottle-only.""" + with self.assertRaises(ManifestError) as cm: + ManifestIndex.from_json_obj({ + "bottles": {"dev": {}}, + "agents": { + "demo": { + "skills": [], + "prompt": "", + "bottle": "dev", + "git-gate": {"signing": {"enabled": True}}, + }, + }, + }) + msg = str(cm.exception) + self.assertIn("git-gate.signing", msg) + self.assertIn("not allowed at the agent level", msg) + + +class TestSigningExtendsOverlay(unittest.TestCase): + def test_child_inherits_parent_signing(self): + """A child that omits signing inherits the parent's enabled flag.""" + m = ManifestIndex.from_json_obj({ + "bottles": { + "base": {"git-gate": {"signing": {"enabled": True}}}, + "dev": {"extends": "base"}, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + self.assertTrue(m.bottles["dev"].git_signing.enabled) + + def test_child_enables_over_disabled_parent(self): + m = ManifestIndex.from_json_obj({ + "bottles": { + "base": {}, + "dev": {"extends": "base", "git-gate": {"signing": {"enabled": True}}}, + }, + "agents": {"demo": {"skills": [], "prompt": "", "bottle": "dev"}}, + }) + self.assertTrue(m.bottles["dev"].git_signing.enabled) + + +if __name__ == "__main__": + unittest.main()