feat(manifest): git-gate.signing opt-in (PRD chunk 2)
test / integration-docker (pull_request) Successful in 38s
lint / lint (push) Successful in 56s
test / unit (pull_request) Successful in 52s
test / integration-firecracker (pull_request) Successful in 3m48s
test / coverage (pull_request) Successful in 15s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 24s
test / integration-docker (pull_request) Successful in 38s
lint / lint (push) Successful in 56s
test / unit (pull_request) Successful in 52s
test / integration-firecracker (pull_request) Successful in 3m48s
test / coverage (pull_request) Successful in 15s
test / publish-infra (pull_request) Has been skipped
tracker-policy-pr / check-pr (pull_request) Failing after 24s
Implements the manifest surface for the signed-commits & audit-attribution PRD (#423, PR #480): a bottle opts into per-activation commit signing with git-gate: signing: enabled: true - New ManifestGitSigning value type (frozen, enabled: bool = False) parsed under git-gate.signing; unknown sub-keys and non-bool `enabled` die with targeted errors. parse_git_gate_config now returns (repos, user, signing). - ManifestBottle gains git_signing; defaults to off, so bottles that omit the block are unchanged. - Bottle-only: git-gate.signing on an agent is rejected by the existing agent-level non-`user` guard (like git-gate.repos). - extends/runtime merges thread git_signing with an enable-wins overlay (a child enabling signing wins; omitting inherits the parent), mirroring the git_user non-empty-wins overlay. - Facade exports ManifestGitSigning. Tests: tests/unit/test_manifest_git_signing.py (parse, validation, agent-level rejection, extends overlay). Full unit suite green. This is the first stacked chunk; signing pipeline, gate signature check, and control-plane verification + audit tables follow per the PRD. Issue: #423 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -66,7 +66,12 @@ if TYPE_CHECKING:
|
|||||||
from .agent import ManifestAgent, ManifestAgentProvider
|
from .agent import ManifestAgent, ManifestAgentProvider
|
||||||
from .bottle import ManifestBottle
|
from .bottle import ManifestBottle
|
||||||
from .egress import EGRESS_AUTH_SCHEMES, ManifestEgressConfig, ManifestEgressRoute
|
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`,
|
# Facade name -> submodule that defines it. The aggregate model (`Manifest`,
|
||||||
@@ -82,6 +87,7 @@ _LAZY_MODULES: dict[str, str] = {
|
|||||||
"ManifestEgressRoute": "egress",
|
"ManifestEgressRoute": "egress",
|
||||||
"ManifestEgressConfig": "egress",
|
"ManifestEgressConfig": "egress",
|
||||||
"ManifestGitEntry": "git",
|
"ManifestGitEntry": "git",
|
||||||
|
"ManifestGitSigning": "git",
|
||||||
"ManifestGitUser": "git",
|
"ManifestGitUser": "git",
|
||||||
"ManifestKeyConfig": "git",
|
"ManifestKeyConfig": "git",
|
||||||
}
|
}
|
||||||
@@ -107,6 +113,7 @@ __all__ = [
|
|||||||
"ManifestIndex",
|
"ManifestIndex",
|
||||||
"ManifestError",
|
"ManifestError",
|
||||||
"ManifestGitEntry",
|
"ManifestGitEntry",
|
||||||
|
"ManifestGitSigning",
|
||||||
"ManifestGitUser",
|
"ManifestGitUser",
|
||||||
"ManifestKeyConfig",
|
"ManifestKeyConfig",
|
||||||
"ManifestAgentProvider",
|
"ManifestAgentProvider",
|
||||||
|
|||||||
@@ -18,7 +18,12 @@ from typing import Mapping
|
|||||||
from .util import ManifestError, as_json_object
|
from .util import ManifestError, as_json_object
|
||||||
from .agent import ManifestAgentProvider
|
from .agent import ManifestAgentProvider
|
||||||
from .egress import ManifestEgressConfig
|
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
|
from .schema import BOTTLE_KEYS
|
||||||
|
|
||||||
__all__ = ["ManifestBottle"]
|
__all__ = ["ManifestBottle"]
|
||||||
@@ -38,6 +43,10 @@ class ManifestBottle:
|
|||||||
# `git config --global` step entirely. A bottle can declare a user
|
# `git config --global` step entirely. A bottle can declare a user
|
||||||
# identity without any git-gate.repos upstreams, and vice versa.
|
# identity without any git-gate.repos upstreams, and vice versa.
|
||||||
git_user: ManifestGitUser = field(default_factory=ManifestGitUser)
|
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)
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
# Per-bottle stuck-recovery daemon (PRD 0013). When true (the
|
# Per-bottle stuck-recovery daemon (PRD 0013). When true (the
|
||||||
# default, issue #249), the launch step brings up a supervise
|
# default, issue #249), the launch step brings up a supervise
|
||||||
@@ -109,9 +118,10 @@ class ManifestBottle:
|
|||||||
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
git_user = ManifestGitUser()
|
git_user = ManifestGitUser()
|
||||||
|
git_signing = ManifestGitSigning()
|
||||||
git_raw = d.get("git-gate")
|
git_raw = d.get("git-gate")
|
||||||
if git_raw is not None:
|
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 = (
|
agent_provider = (
|
||||||
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
ManifestAgentProvider.from_dict(name, d["agent_provider"])
|
||||||
@@ -141,7 +151,8 @@ class ManifestBottle:
|
|||||||
|
|
||||||
return cls(
|
return cls(
|
||||||
env=env, agent_provider=agent_provider, git=git,
|
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,
|
nested_containers=nested_raw,
|
||||||
declared_fields=frozenset(d),
|
declared_fields=frozenset(d),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from .bottle import ManifestBottle
|
from .bottle import ManifestBottle
|
||||||
from .egress import ManifestEgressConfig, validate_egress_routes
|
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
|
from .util import ManifestError, as_json_object
|
||||||
|
|
||||||
|
|
||||||
@@ -19,6 +19,17 @@ def _overlay_declared_bool(
|
|||||||
return value
|
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":
|
def merge_bottles_runtime(bottles: "list[ManifestBottle]") -> "ManifestBottle":
|
||||||
"""Merge an ordered list of pre-resolved ManifestBottle objects.
|
"""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,
|
agent_provider=override.agent_provider,
|
||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
|
git_signing=_overlay_signing(base, override),
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=_overlay_declared_bool(base, override, "supervise"),
|
supervise=_overlay_declared_bool(base, override, "supervise"),
|
||||||
nested_containers=_overlay_declared_bool(
|
nested_containers=_overlay_declared_bool(
|
||||||
@@ -210,7 +222,7 @@ def _fold_two_bottles(
|
|||||||
for n in names
|
for n in names
|
||||||
}
|
}
|
||||||
if merged_repos_raw:
|
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:
|
else:
|
||||||
merged_git = ()
|
merged_git = ()
|
||||||
|
|
||||||
@@ -225,6 +237,7 @@ def _fold_two_bottles(
|
|||||||
agent_provider=later.agent_provider,
|
agent_provider=later.agent_provider,
|
||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
|
git_signing=_overlay_signing(earlier, later),
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=_overlay_declared_bool(earlier, later, "supervise"),
|
supervise=_overlay_declared_bool(earlier, later, "supervise"),
|
||||||
nested_containers=_overlay_declared_bool(
|
nested_containers=_overlay_declared_bool(
|
||||||
@@ -299,6 +312,7 @@ def _merge_bottles(
|
|||||||
agent_provider=merged_agent_provider,
|
agent_provider=merged_agent_provider,
|
||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
|
git_signing=_overlay_signing(parent, child),
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=merged_supervise,
|
supervise=merged_supervise,
|
||||||
nested_containers=merged_nested_containers,
|
nested_containers=merged_nested_containers,
|
||||||
|
|||||||
@@ -283,16 +283,52 @@ class ManifestGitUser:
|
|||||||
return not self.name and not self.email
|
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(
|
def parse_git_gate_config(
|
||||||
bottle_name: str,
|
bottle_name: str,
|
||||||
raw: object,
|
raw: object,
|
||||||
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser]:
|
) -> tuple[tuple[ManifestGitEntry, ...], ManifestGitUser, ManifestGitSigning]:
|
||||||
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
d = as_json_object(raw, f"bottle '{bottle_name}' git-gate")
|
||||||
for k in d:
|
for k in d:
|
||||||
if k not in {"user", "repos"}:
|
if k not in {"user", "repos", "signing"}:
|
||||||
raise ManifestError(
|
raise ManifestError(
|
||||||
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
f"bottle '{bottle_name}' git-gate has unknown key {k!r}; "
|
||||||
f"allowed: user, repos"
|
f"allowed: user, repos, signing"
|
||||||
)
|
)
|
||||||
|
|
||||||
git_user = (
|
git_user = (
|
||||||
@@ -301,6 +337,12 @@ def parse_git_gate_config(
|
|||||||
else ManifestGitUser()
|
else ManifestGitUser()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
git_signing = (
|
||||||
|
ManifestGitSigning.from_dict(bottle_name, d["signing"])
|
||||||
|
if "signing" in d
|
||||||
|
else ManifestGitSigning()
|
||||||
|
)
|
||||||
|
|
||||||
git: tuple[ManifestGitEntry, ...] = ()
|
git: tuple[ManifestGitEntry, ...] = ()
|
||||||
repos_raw = d.get("repos")
|
repos_raw = d.get("repos")
|
||||||
if repos_raw is not None:
|
if repos_raw is not None:
|
||||||
@@ -311,4 +353,4 @@ def parse_git_gate_config(
|
|||||||
)
|
)
|
||||||
validate_unique_git_names(bottle_name, git)
|
validate_unique_git_names(bottle_name, git)
|
||||||
|
|
||||||
return git, git_user
|
return git, git_user, git_signing
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user