Compare commits

...

11 Commits

Author SHA1 Message Date
didericis-claude e16efc86ad 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
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>
2026-07-26 02:52:47 +00:00
didericis-claude 010e253d66 docs: sharpen the attribution guarantee to a byte<->activation-key binding
tracker-policy-pr / check-pr (pull_request) Successful in 6s
Revised per PR #480 (#5607 owner clarification + #5608 codex resolution;
#5612 directs the update):

- The audit row no longer implies upstream observation or agent-only
  authorship. Reworded the guarantee: the row cryptographically binds
  commit bytes (control-plane-RECOMPUTED SHA) to access to the activation
  signing key, and binds that key to control-plane-owned activation
  metadata. An agent can sign arbitrary contents but cannot verify as a
  different activation or choose the recorded metadata.
- Control plane accepts gateway-delivered opaque bytes, independently
  recomputes the Git object ID, verifies the embedded signature against
  the activation key, and stamps its own metadata. Trusts no gateway
  SHA/key/verdict/metadata. No upstream fetch.
- Purged overclaims: removed "a compromised gateway cannot fabricate an
  audit binding" (the sidecar holds the signing capability, so it can —
  and that's acceptable under the intended guarantee), plus "accepted
  push" / "introduced upstream" framing.
- Resolved the control-plane-transport open question in-PRD (was left
  open; codex asked to resolve): transport is gateway bytes +
  recompute + verify; mirror-read is no stronger.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 02:23:13 +00:00
didericis-claude 245f258f20 docs: drop author/committer enforcement; anchor audit in control plane
tracker-policy-pr / check-pr (pull_request) Successful in 7s
lint / lint (push) Successful in 2m44s
Revised per PR #480 review (#5590 + didericis-codex review on d8362ec):

- Remove author/committer enforcement entirely (#5590). The gate no
  longer matches identity fields; author/committer are recorded as
  claims in the audit store. Drop the git-gate.signing.enforce knob
  (which also resolves codex issue 1: a knob that weakened the stated
  guarantee). Add a "Deferred: identity enforcement" section noting it
  as a possible future add. Rename PRD/file to "signed commits & audit
  attribution" since identity is no longer guaranteed.
- Fix control-plane vs data-plane verification (codex issue 2, PRD 0070):
  git-gate (data plane) does a synchronous pre-forward SIGNATURE check
  only; the orchestrator/control plane (sole owner of bot-bottle.db)
  independently re-verifies each signature before writing attributed_commit.
  A gateway assertion alone never creates an audit row. New "Trust
  boundary" + "Control-plane verification & recording" sections.
- Reframe the guarantee to signed provenance + host-owned, independently
  verified audit record; ADR 0002 "claimed, not vouched" posture kept.
- attributed_commit now records claimed author/committer columns.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:18:48 -04:00
didericis-claude c62d57d5ac docs: narrow PRD to per-bottle signed identity & audit attribution
Revised per PR #480 review (#5518#5556):

- Rename: "forge subroles" → "per-bottle signed identity & audit
  attribution"; rename the file to match.
- Reframe the guarantee as bottle/activation provenance, not
  cryptographically-vouched author identity. Author/committer name/email
  is a claim carried inside the signed object, made trustworthy by a
  git-gate acceptance check + the host record, not by the signature.
- Add the gate-side acceptance check: on push, every newly-introduced
  commit (excluding upstream-reachable history) must verify against the
  activation key AND match git-gate.user in both author and committer
  fields, else the push is rejected. Host verifies the signature before
  recording a SHA as attributed.
- Audit: retain full public key + fingerprint + principal + validity
  interval (not fingerprint-only); state allowed-signers generation.
- Drop from scope: forge subuser accounts, provisioned API tokens/PAT
  minting, forge status/Verified badges -> future "forge actors" PRD.
  This removes the Gitea PAT bootstrap problem entirely.
- Manifest: drop git-forge/forge-accounts; reuse git-gate.user as the
  enforced identity + add opt-in git-gate.signing. Push stays PRD 0048
  deploy keys, unchanged.

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:18:48 -04:00
didericis-claude 34bb7263fa docs: PRD for forge subroles (per-bottle subuser identity & vouched attribution)
Formalizes the design settled in issue #423: one forge subrole identity
per bottled agent (author + forge account + SSH signing key), reused
across all repos/forges. Vouched attribution via sign-at-commit-time in
the git-gate boundary (forwarded ssh-agent; private key never in the
bottle; no SHA divergence). Forge "Verified" badges abandoned in favor
of local git verify-commit plus durable console audit records and
commit-status badges. Reprovision-per-activation credential lifecycle
(0048 discipline), fail-loud teardown, public-key-fingerprint-only audit
trail on bottled_agent.

Successor to PRD 0027 (claimed-not-vouched, ADR 0002) and PRD 0048
(host-side minting lifecycle).

Issue: #423

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-25 22:18:48 -04:00
Quality Badge Bot 2644759b0d chore: update quality badges
- Coverage: 84%
- Core coverage: 94%

[skip ci]
2026-07-26 01:49:46 +00:00
didericis-claude e53104d5c1 refactor(orchestrator): fail closed unconditionally, drop topology opt-out
tracker-policy-pr / check-pr (pull_request) Successful in 7s
test / integration-docker (pull_request) Successful in 15s
test / unit (pull_request) Successful in 39s
test / integration-firecracker (pull_request) Successful in 3m55s
test / coverage (pull_request) Successful in 22s
test / publish-infra (pull_request) Has been skipped
prd-number / assign-numbers (push) Failing after 21s
test / integration-docker (push) Successful in 22s
lint / lint (push) Successful in 56s
Update Quality Badges / update-badges (push) Successful in 53s
test / unit (push) Successful in 1m52s
test / integration-firecracker (push) Successful in 5m2s
test / coverage (push) Successful in 16s
test / publish-infra (push) Successful in 1m56s
Remove the empty-key opt-out codex flagged: ControlPlaneProvisioning
no longer lets the orchestrator start without a signing key when a
backend declares an "isolated" topology. Host separation is not the
safety condition — the gateway (and any other caller) must reach the
control-plane listener for /resolve, so an open orchestrator would
still grant them full `cli`. The signing key is now mandatory for
every backend.

Drops the now-purposeless Topology/COLOCATED machinery (no backend
declared a non-default topology) and the contractual
test_orchestrator_key_allows_empty_when_isolated. Updates the PRD's
invariant 4 and lifecycle docstring accordingly. Docs/behaviour only
otherwise.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:40:19 +00:00
didericis-claude 8f6148d571 docs(orchestrator): tighten auth-provisioning wording to concrete services
test / integration-docker (pull_request) Successful in 18s
tracker-policy-pr / check-pr (pull_request) Successful in 21s
test / integration-firecracker (pull_request) Successful in 3m51s
test / unit (pull_request) Failing after 11m53s
test / coverage (pull_request) Has been skipped
test / publish-infra (pull_request) Has been skipped
Address review on #482: drop the generic "credential boundary" framing in the
PRD and docstrings and talk about the specific services — the orchestrator holds
the control-plane key; the host controller (#468) gets a separate key the
orchestrator never holds, so the orchestrator can't mint the credentials it uses
to talk to the host controller that owns its lifecycle. Lead with that concrete
win. No code behaviour change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 01:32:53 +00:00
didericis-claude 45f3cefbc5 refactor(orchestrator): uniform control-plane auth provisioning per trust domain
Hoist control-plane auth provisioning out of the per-backend launchers into
one shared contract, parameterized per trust domain (#476). Every blocking
finding in PR #471 was the same integration-bug class: each launcher
re-derived, by hand, how to generate the signing key, scope it to the
orchestrator, mint the gateway JWT, and keep the host key canonical.

Introduces `trust_domain.py`:

  * `TrustDomain` — one credential boundary (host-canonical key file + role
    set + env vars). `mint`/`verify` are scoped to the domain's roles, so a
    future host-controller domain (#468) uses its own key/verifier/roles
    rather than a `host` role on the control plane's frozenset (which the
    orchestrator key could then forge).
  * `ControlPlaneProvisioning` — the single seam answering the four
    invariants: host-canonical key, split key-vs-token credential, CLI token
    valid across co-running backends, and fail-closed (no open mode) for any
    co-located topology.
  * `Topology` — the backend declares what it is; the default is co-located +
    fail-closed, so a backend need not redeclare it.

The `Orchestrator` ABC gets `control_plane_key()` (fail-closed) and routes
`mint_gateway_token()` through the contract; docker/macOS/firecracker
orchestrators, the server (verify), and the host CLI client (mint cli) all go
through the domain instead of reading the host key directly. `orchestrator_auth`
gains an optional `roles=` arg (default unchanged) so a domain scopes its own
role set; `paths.host_signing_key(filename)` generalizes host_orchestrator_token.

Adds unit coverage for the domain boundary + provisioning invariants and a PRD
capturing the durable rationale. No change to the auth primitive's HMAC, the
plane split, or the server's documented open-mode fallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Closes #476
2026-07-26 01:32:53 +00:00
Quality Badge Bot 0e70d26af4 chore: update quality badges
- Coverage: 83%
- Core coverage: 94%

[skip ci]
2026-07-25 23:45:18 +00:00
didericis-codex f2d8158742 Merge pull request 'fix(supervise): get bot-bottle.db off the data plane (supervise + egress proposals over RPC)' (#471) from fix/db-off-data-plane-469 into main
test / integration-docker (push) Successful in 35s
test / unit (push) Successful in 49s
Update Quality Badges / update-badges (push) Successful in 50s
lint / lint (push) Successful in 57s
test / integration-firecracker (push) Successful in 5m10s
test / coverage (push) Successful in 16s
test / publish-infra (push) Successful in 2m18s
2026-07-25 19:44:20 -04:00
22 changed files with 1120 additions and 58 deletions
+3 -2
View File
@@ -19,7 +19,6 @@ from .util import run_docker
from ...paths import (
ORCHESTRATOR_TOKEN_ENV,
bot_bottle_root,
host_orchestrator_token,
)
from ...gateway import GatewayError
from ...orchestrator.lifecycle import (
@@ -171,7 +170,9 @@ class DockerOrchestrator(Orchestrator):
fixed-name container first)."""
self._ensure_control_network()
run_docker(["docker", "rm", "--force", self.name])
_signing_key = host_orchestrator_token()
# The signing key comes through the shared provisioning contract (#476),
# which fail-closes rather than yield an empty key that would run OPEN.
_signing_key = self.control_plane_key()
proc = run_docker([
"docker", "run", "--detach",
"--name", self.name,
@@ -21,7 +21,6 @@ import time
from pathlib import Path
from ...log import die, info
from ...paths import host_orchestrator_token
from ...orchestrator.lifecycle import (
DEFAULT_STARTUP_TIMEOUT_SECONDS,
Orchestrator,
@@ -108,11 +107,13 @@ class FirecrackerOrchestrator(Orchestrator):
data_drive=self._ensure_registry_volume(),
)
# Push the host-canonical signing key (the init waits for it before
# starting the control plane). The host token file stays the single
# source of truth, so a co-running docker/macOS control plane keeps
# working; the guest verifies tokens with the same key the CLI signs from.
# starting the control plane). It comes through the shared provisioning
# contract (#476) — the same host token file every backend uses, so a
# co-running docker/macOS control plane keeps working and the guest
# verifies tokens with the same key the CLI signs from; fail-closed, so
# the guest is never handed an empty key that would run it OPEN.
infra_vm.push_secret(
vm, host_orchestrator_token(), infra_vm._GUEST_SIGNING_KEY_PATH,
vm, self.control_plane_key(), infra_vm._GUEST_SIGNING_KEY_PATH,
"the control-plane signing key to the orchestrator VM "
"(its control plane will not start)",
)
@@ -18,10 +18,7 @@ import urllib.request
from pathlib import Path
from ... import log
from ...paths import (
ORCHESTRATOR_TOKEN_ENV,
host_orchestrator_token,
)
from ...paths import ORCHESTRATOR_TOKEN_ENV
from ...orchestrator.lifecycle import (
DEFAULT_HEALTH_TIMEOUT_SECONDS,
DEFAULT_PORT,
@@ -134,7 +131,9 @@ class MacosOrchestrator(Orchestrator):
def _run_container(self, current_hash: str) -> None:
container_mod.force_remove_container(self.name)
_signing_key = host_orchestrator_token()
# The signing key comes through the shared provisioning contract (#476),
# which fail-closes rather than yield an empty key that would run OPEN.
_signing_key = self.control_plane_key()
argv = [
"container", "run", "--detach",
"--name", self.name,
+8 -1
View File
@@ -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",
+14 -3
View File
@@ -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),
)
+16 -2
View File
@@ -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,
+46 -4
View File
@@ -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
+3 -3
View File
@@ -18,8 +18,8 @@ import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from ..orchestrator_auth import ROLE_CLI, mint
from ..paths import host_orchestrator_token
from ..orchestrator_auth import ROLE_CLI
from ..trust_domain import CONTROL_PLANE
from .server import ORCHESTRATOR_AUTH_HEADER
DEFAULT_TIMEOUT_SECONDS = 5.0
@@ -32,7 +32,7 @@ def _host_auth_token() -> str:
"" means 'send no auth header' correct against an open (unconfigured)
control plane, and harmlessly rejected by a secured one."""
try:
return mint(ROLE_CLI, host_orchestrator_token())
return CONTROL_PLANE.mint(ROLE_CLI)
except (OSError, ValueError):
return ""
+19 -5
View File
@@ -21,8 +21,7 @@ import urllib.error
import urllib.request
from pathlib import Path
from ..orchestrator_auth import ROLE_GATEWAY, mint
from ..paths import host_orchestrator_token
from ..trust_domain import ControlPlaneProvisioning
DEFAULT_PORT = 8099
DEFAULT_STARTUP_TIMEOUT_SECONDS = 45.0
@@ -58,6 +57,12 @@ class Orchestrator(abc.ABC):
so it not the gateway mints the gateway's role-scoped token.
Backend-neutral."""
# The shared control-plane auth provisioning contract (#476). Every backend
# gets its signing key + gateway token through this one seam rather than
# re-deriving the wiring; it is fail-closed for every backend — the
# orchestrator never starts without its signing key.
provisioning: ControlPlaneProvisioning = ControlPlaneProvisioning()
def ensure_built(self) -> None:
"""Ensure the orchestrator's image / rootfs exists, building it if
needed. Default: nothing to build (e.g. a pre-pulled image). Call before
@@ -105,9 +110,17 @@ class Orchestrator(abc.ABC):
def mint_gateway_token(self) -> str:
"""Mint a role-scoped `gateway` JWT from the host signing key for the
gateway to present. The orchestrator holds the key; the gateway never
does (#469). Backend-neutral — the same host token file is the single
source of truth across backends."""
return mint(ROLE_GATEWAY, host_orchestrator_token())
does (#469). Routed through the shared provisioning contract (#476), so
the same host token file is the single source of truth across backends."""
return self.provisioning.gateway_token()
def control_plane_key(self) -> str:
"""The raw signing key the control-plane *process* must receive — the ONE
place a backend obtains it (docker/macOS inject it as `key_env`;
firecracker pushes it to the guest). Fail-closed via the provisioning
contract: it raises rather than yield an empty key that would run the
server OPEN (#476)."""
return self.provisioning.orchestrator_key()
__all__ = [
@@ -117,4 +130,5 @@ __all__ = [
"OrchestratorStartError",
"source_hash",
"Orchestrator",
"ControlPlaneProvisioning",
]
+7 -5
View File
@@ -63,8 +63,8 @@ import sys
import typing
from urllib.parse import urlsplit
from ..orchestrator_auth import ROLE_CLI, ROLES, verify
from ..paths import ORCHESTRATOR_TOKEN_ENV
from ..orchestrator_auth import ROLE_CLI, ROLES
from ..trust_domain import CONTROL_PLANE
from ..supervisor.types import TOOLS
from .service import OrchestratorCore
@@ -413,11 +413,13 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
def __init__(self, address: tuple[str, int], orchestrator: OrchestratorCore) -> None:
self.orchestrator = orchestrator
self._signing_key = os.environ.get(ORCHESTRATOR_TOKEN_ENV, "").strip()
# The control-plane trust domain's signing key, as injected into THIS
# (the owning) process by the launcher (#476). Unset → open mode below.
self._signing_key = CONTROL_PLANE.key_from_env()
if not self._signing_key:
sys.stderr.write(
"orchestrator: WARNING — no control-plane signing key "
f"(${ORCHESTRATOR_TOKEN_ENV}); running WITHOUT caller "
f"(${CONTROL_PLANE.key_env}); running WITHOUT caller "
"authentication. Any client that can reach this port can drive "
"it. Backends that put the control plane on an agent-reachable "
"network MUST set this.\n"
@@ -433,7 +435,7 @@ class OrchestratorServer(socketserver.ThreadingMixIn, http.server.HTTPServer):
role ( per-route 401/403 in `dispatch`)."""
if not self._signing_key:
return ROLE_CLI
return verify(presented, self._signing_key)
return CONTROL_PLANE.verify(presented, self._signing_key)
def make_server(
+14 -8
View File
@@ -59,12 +59,17 @@ _HEADER_SEGMENT = _b64url_encode(
)
def mint(role: str, secret: str) -> str:
def mint(role: str, secret: str, *, roles: frozenset[str] = ROLES) -> str:
"""A compact HS256 token asserting `role`, signed with `secret`.
Raises ValueError for an unknown role (mint only what the control plane will
accept) or an empty signing key (an unsigned credential is never valid)."""
if role not in ROLES:
`roles` is the set the signing key is allowed to sign (default: the
orchestrator's `{gateway, cli}`). A separate service (e.g. the host
controller) passes its own key + role set so its tokens can't be forged with
the orchestrator's key — see `trust_domain.py`, issues #476/#468.
Raises ValueError for a role outside `roles`, or an empty signing key (an
unsigned credential is never valid)."""
if role not in roles:
raise ValueError(f"unknown control-plane role {role!r}")
if not secret:
raise ValueError("cannot mint a control-plane token without a signing key")
@@ -73,10 +78,11 @@ def mint(role: str, secret: str) -> str:
return f"{signing_input}.{_sign(secret, signing_input)}"
def verify(token: str, secret: str) -> str | None:
def verify(token: str, secret: str, *, roles: frozenset[str] = ROLES) -> str | None:
"""The role a valid `token` carries, or None if it is malformed, wrongly
signed, or names an unknown role. Constant-time signature check; rejects any
header whose alg isn't HS256 (no alg-confusion / `none`)."""
signed, or names a role outside `roles` (the verifying trust domain's set —
default `{gateway, cli}`). Constant-time signature check; rejects any header
whose alg isn't HS256 (no alg-confusion / `none`)."""
if not token or not secret:
return None
parts = token.split(".")
@@ -94,7 +100,7 @@ def verify(token: str, secret: str) -> str | None:
if not isinstance(header, dict) or header.get("alg") != _ALG:
return None
role = payload.get("role") if isinstance(payload, dict) else None
return role if isinstance(role, str) and role in ROLES else None
return role if isinstance(role, str) and role in roles else None
__all__ = ["ROLE_GATEWAY", "ROLE_CLI", "ROLES", "mint", "verify"]
+19 -9
View File
@@ -97,16 +97,17 @@ def host_gateway_ca_dir() -> Path:
return ca_dir
def host_orchestrator_token() -> str:
"""The per-host control-plane secret, minted (256-bit, url-safe) and
persisted 0600 on first use, then reused.
def host_signing_key(filename: str) -> str:
"""A per-host signing key at `<root>/<filename>`, minted (256-bit, url-safe)
and persisted 0600 on first use, then reused.
This is the shared secret the launchers inject into the control-plane and
gateway containers and that the host CLI presents on every call. It is a
*host* artifact the file lives under the root the agent never mounts, and
the env var is set only on the trusted containers so reading it here is
safe on the host launch path but the value never reaches a bottle."""
path = bot_bottle_root() / ORCHESTRATOR_TOKEN_FILENAME
The generic form of `host_orchestrator_token()`: each service names its own
key file (`trust_domain.py`), so the orchestrator and a separate service like
the host controller (#468) get distinct keys neither can read. It is a *host*
artifact the file lives under the root the agent never mounts, and its value
is injected only into the trusted control-plane process so reading it here
is safe on the launch path but the value never reaches a bottle."""
path = bot_bottle_root() / filename
try:
existing = path.read_text().strip()
if existing:
@@ -128,6 +129,14 @@ def host_orchestrator_token() -> str:
return token
def host_orchestrator_token() -> str:
"""The per-host control-plane signing key — the host-canonical key the
launchers inject into the control-plane process and the host CLI mints its
own `cli` token from. The `control-plane` trust domain's specialization of
`host_signing_key()`."""
return host_signing_key(ORCHESTRATOR_TOKEN_FILENAME)
__all__ = [
"HOST_DB_FILENAME",
"ORCHESTRATOR_TOKEN_FILENAME",
@@ -138,5 +147,6 @@ __all__ = [
"host_db_path",
"host_db_dir",
"host_gateway_ca_dir",
"host_signing_key",
"host_orchestrator_token",
]
+140
View File
@@ -0,0 +1,140 @@
"""Per-service control-plane signing keys (issue #476).
A `TrustDomain` is one service's signing material: its host-canonical key file,
the roles that key may sign, and the env vars its key and a pre-minted token ride
in. Scoping `mint`/`verify` to a domain's roles keeps one service's key from
signing (or accepting) another service's tokens.
Today there is one domain, `CONTROL_PLANE` the orchestrator's key (roles
`{gateway, cli}`): the orchestrator holds it and mints the gateway's and CLI's
tokens. The host controller (#468) will add a **second** domain with its own key
the orchestrator never holds. That is the point: the host controller starts and
stops the orchestrator, so the orchestrator must not be able to mint the
credentials it uses to talk to it. Adding a `host` role to `CONTROL_PLANE`
instead would defeat that the orchestrator holds that key, so it could forge
`host` tokens.
`ControlPlaneProvisioning` is the one seam every backend launcher uses to get the
orchestrator its key and the gateway its token, instead of re-deriving that
wiring per backend (the bug class behind PR #471 — see
`docs/prds/prd-new-control-plane-auth-provisioning.md`).
Stdlib-only: the HMAC lives in `orchestrator_auth`, the key file in `paths`.
"""
from __future__ import annotations
import os
from collections.abc import Mapping
from dataclasses import dataclass
from . import orchestrator_auth
from .orchestrator_auth import ROLE_GATEWAY
from .paths import (
ORCHESTRATOR_AUTH_JWT_ENV,
ORCHESTRATOR_TOKEN_ENV,
ORCHESTRATOR_TOKEN_FILENAME,
host_signing_key,
)
class ProvisioningError(RuntimeError):
"""A control-plane auth invariant would be violated (e.g. starting the
orchestrator without its signing key which would run OPEN)."""
@dataclass(frozen=True)
class TrustDomain:
"""One service's signing material: a host-canonical key file, the roles that
key may sign, and the env vars its key and a minted token ride in.
The service that *owns* the domain (e.g. the orchestrator) receives the raw
key via `key_env`; a delegate (e.g. the gateway) receives only a pre-minted,
role-scoped token via `token_env` it cannot rewrite. `mint`/`verify` are
scoped to `roles`, so this service's key can neither sign nor accept another
service's role."""
name: str
key_filename: str
roles: frozenset[str]
key_env: str
token_env: str
def signing_key(self) -> str:
"""This service's host-canonical signing key (minted 0600 on first use).
Host-side only the value is injected into the owning process."""
return host_signing_key(self.key_filename)
def key_from_env(self, environ: Mapping[str, str] | None = None) -> str:
"""The signing key as the owning process sees it — read from `key_env`
(default `os.environ`). "" when unset; the caller decides whether that is
fatal (`ControlPlaneProvisioning`) or the open-mode fallback
(`OrchestratorServer`)."""
env = os.environ if environ is None else environ
return env.get(self.key_env, "").strip()
def mint(self, role: str) -> str:
"""A role-scoped token for a delegate, signed with this service's key.
Raises ValueError for a role this service doesn't sign."""
if role not in self.roles:
raise ValueError(f"role {role!r} is not in trust domain {self.name!r}")
return orchestrator_auth.mint(role, self.signing_key(), roles=self.roles)
def verify(self, token: str, key: str) -> str | None:
"""The role `token` carries under `key`, or None. `key` is passed in
(not read from disk) because the verifier the control-plane process
holds it in `key_env`, not on disk in its guest."""
return orchestrator_auth.verify(token, key, roles=self.roles)
# The orchestrator's domain: the key the orchestrator (and host CLI) holds, the
# `gateway` token it mints for the data plane, and the `cli` token the CLI mints
# for itself. #468's host controller will add a second, separate domain.
CONTROL_PLANE = TrustDomain(
name="control-plane",
key_filename=ORCHESTRATOR_TOKEN_FILENAME,
roles=orchestrator_auth.ROLES,
key_env=ORCHESTRATOR_TOKEN_ENV,
token_env=ORCHESTRATOR_AUTH_JWT_ENV,
)
@dataclass(frozen=True)
class ControlPlaneProvisioning:
"""The one seam every backend launcher uses to provision control-plane auth,
instead of re-deriving the four invariants that each cost a PR #471 review
round: the orchestrator gets the raw key (`orchestrator_key`), the gateway
gets a minted `gateway` token (`gateway_token`), the host CLI mints its own
`cli` token from the same host-canonical key, and the orchestrator never
starts open."""
domain: TrustDomain = CONTROL_PLANE
def orchestrator_key(self) -> str:
"""The raw signing key the orchestrator process must receive (carry it in
`domain.key_env`). Fail-closed: raises rather than return "", since an
empty key runs the server open and being on a separate host does not
stop the gateway from reaching the control plane (it must, for
`/resolve`), so an open orchestrator would treat that gateway as `cli`."""
key = self.domain.signing_key()
if not key:
raise ProvisioningError(
f"refusing to start the {self.domain.name} orchestrator without "
"a signing key: an open orchestrator authenticates no one and "
"grants every caller that reaches it full `cli` (#476)"
)
return key
def gateway_token(self) -> str:
"""The `gateway`-role token the gateway receives (carry it in
`domain.token_env`) minted from the key, never the key itself, so a
compromised gateway cannot forge a `cli` token."""
return self.domain.mint(ROLE_GATEWAY)
__all__ = [
"ProvisioningError",
"TrustDomain",
"CONTROL_PLANE",
"ControlPlaneProvisioning",
]
@@ -0,0 +1,89 @@
# PRD prd-new: Per-service signing keys for control-plane auth
- **Status:** Draft
- **Author:** claude
- **Created:** 2026-07-26
- **Issue:** #476
## Summary
Provision control-plane signing keys **per service**, through one shared seam, so
no service can mint another's credentials. Concretely: the orchestrator holds the
control-plane key and mints the gateway's and CLI's tokens; the host controller
(#468, next) gets a **separate** key the orchestrator never holds — so the
orchestrator cannot forge the credentials it uses to talk to the host controller
that starts and stops it. Landing this seam also retires the per-backend
provisioning duplication that made PR #471 take three review rounds.
## Problem
**1. The orchestrator could forge host-controller credentials.** The
orchestrator's key signs roles `{gateway, cli}`. The tempting way to add the host
controller (#468) is a third role, `host`, on that same key. But then the
orchestrator — which holds the key — can mint `host` tokens, and the host
controller, which owns the orchestrator's lifecycle, must not trust anything the
orchestrator can mint. The two services need separate keys.
**2. Every backend provisioned auth by hand.** Each launcher (docker
gateway/infra, macOS infra, firecracker infra) re-derived how to generate the
signing key, scope it to the orchestrator, mint the gateway JWT, and keep the
host key file canonical. All three PR #471 High-severity findings were this one
integration bug in different launchers: the data plane got the full `cli` token;
the firecracker control plane ran open; the firecracker guest clobbered the host
key.
## Goals / Success Criteria
- The orchestrator and the host controller sign with **different** keys; neither
can mint the other's tokens. (This PR provisions the orchestrator's key and
leaves a drop-in seam for the host controller's.)
- One shared provisioning seam every backend uses — a new backend or daemon
implements it instead of rediscovering these four invariants:
1. the signing key is host-canonical: a guest is handed it, never generates or
overwrites it;
2. only the orchestrator process gets the raw key; the gateway gets a
pre-minted `gateway` token it can't rewrite into `cli`;
3. the host CLI's `cli` token is minted from the same key, so it stays valid
across co-running backends;
4. the orchestrator never runs open — the signing key is mandatory, with no
topology opt-out (a separate host does not stop a caller from reaching the
control-plane listener, so it cannot make open mode safe).
## Non-goals
- The host controller itself (#468) — this only provisions the orchestrator's
key and the seam #468 plugs into.
- Rewriting the HMAC primitive: `orchestrator_auth.mint/verify` gain an optional
`roles=` arg (default unchanged) so a key can carry a different role set;
nothing else changes.
- Network topology, the plane split (#469), or the server's open-mode fallback
for tests.
## Design
A **`TrustDomain`** is one service's signing material: its host-canonical key
file, the roles that key may sign, and the env vars its key and a pre-minted
token ride in. `mint`/`verify` are scoped to that domain's roles, so a token
signed by one service's key neither carries nor verifies another service's role.
- `CONTROL_PLANE` — the orchestrator's domain: key `orchestrator-token`, roles
`{gateway, cli}`. The orchestrator process holds the key; the gateway holds
only a minted `gateway` token; the host CLI mints its own `cli` token.
- The host controller (#468) will add a second `TrustDomain` — its own key file
and role(s) — that the orchestrator never holds.
**`ControlPlaneProvisioning`** is the seam the backends call.
`orchestrator_key()` returns the raw key for the control-plane process
(fail-closed for every backend: it raises rather than hand back an empty key that
would run the server open). `gateway_token()` mints the gateway's token. Each backend applies these through its own transport —
docker/macOS inject env vars, firecracker pushes over SSH — but none re-derives
*which* key or role.
`paths.host_signing_key(filename)` generalizes `host_orchestrator_token()` so each
domain names its own key file.
## Open questions
None blocking. #468 adds its `TrustDomain` and a second
`ControlPlaneProvisioning`-shaped consumer; renaming that class to something
service-neutral is a cosmetic call to make then.
@@ -0,0 +1,463 @@
# PRD prd-new: Per-bottle signed commits & audit attribution
- **Status:** Draft
- **Author:** didericis-claude
- **Created:** 2026-07-25
- **Issue:** #423
## Summary
Give each bottled agent a **per-activation signing key** so that every commit it
produces is signed in the git-gate trust boundary (outside the bottle) and
recorded in bot-bottle's own **host-owned audit store**, which is the portable
source of truth. Each row cryptographically binds a commit's bytes (and their
control-plane-recomputed SHA) to **access to that activation's signing key**, and
binds the key to control-plane-owned activation metadata — bottle, host,
manifest, agent, activation interval, retained public key — plus the commit's
*claimed* author. The gate mints a short-lived Ed25519 key at spin-up, holds the
private half in the sidecar `ssh-agent`, and forwards only `SSH_AUTH_SOCK` into
the bottle. The gate rejects any commit it forwards that is not signed by the
activation key; separately, the **control plane** independently recomputes each
commit's object ID and verifies its signature before recording attribution — it
never trusts a SHA, key, or verdict asserted by the gate.
This PRD deliberately does **not** enforce or vouch author/committer identity.
Author/committer name/email are recorded as claims carried inside the signed
object; making the gate reject a mismatching author/committer is a possible
future add (see **Non-goals** and **Deferred: identity enforcement**). Push
capability stays exactly as PRD 0048 deploy keys; forge subuser accounts,
provisioned API tokens, and forge-side status/"Verified" badges remain out of
scope (a future "forge actors" PRD).
Successor to:
- **PRD 0027 (agent git identity, #94)** / **ADR 0002** — established that
`git-gate.user` name/email is *claimed, not vouched*. This PRD keeps that
posture: it adds signed **provenance** and a durable host record, not identity
enforcement.
- **PRD 0048 (deploy-key provisioning, #169)** — the host-side mint-at-spin-up /
revoke-at-teardown lifecycle the signing key follows. Deploy keys are
unchanged.
- **PRD 0070 (per-host orchestrator, #351)** — the orchestrator/control plane is
the sole owner of `bot-bottle.db`; audit verification and recording live
there, not in the data-plane gate (see **Trust boundary**).
## The guarantee
The crisp property this feature provides:
> The **host-owned audit store** binds a set of commit bytes — whose Git object
> ID the control plane **recomputes** itself — to **access to this activation's
> signing key**, and binds that key to **control-plane-owned activation
> metadata**: bottle, host, manifest, agent, activation interval, retained public
> key. An agent may author and sign arbitrary commit contents, but it cannot make
> that signature verify as a *different* activation, and it cannot choose the
> activation metadata the control plane records. The commit's author/committer
> identity is **recorded as a claim**, not enforced or vouched. The forge remains
> only the repository transport/capability layer.
What this does and does not prove (issue #423, comments #5554 / #5607 / #5608):
- It proves **access to activation *Y*'s signing key**: whoever assembled these
commit bytes could sign with that key. Recomputing the object ID and verifying
the embedded signature binds the SHA to activation *Y*, and the control plane's
own records bind *Y*'s key to *Y*'s metadata.
- It does **not** prove the commit was ever pushed, observed upstream, kept
(vs. later reverted or dropped), or produced by the *agent* rather than by any
other holder of the activation signing capability (the sidecar itself). The
store deliberately makes no claim about publication or sole-agent authorship
— the owner's requirement is attribution of *what manifest/agent/etc. was in
use when a commit was signed*, not proof of where the commit went (#5607).
- It does **not** make author/committer identity cryptographically vouched. The
bottle chooses every byte sent through the forwarded agent, so a signature over
`author Mallory <mallory@example>` is just as valid. Those fields are a claim
carried inside the signed object and recorded as-is.
- The binding is trustworthy because the **control plane** supplies the SHA (it
recomputes it), the public key, and the activation metadata from its own state
— never from a value the gateway asserts (see **Trust boundary**). The
residual, by design: anything that holds the activation signing capability can
produce commits that attribute to that activation. That is inherent to a
binding on *activation-key access*, not a defect.
## Problem
An agent runs on the developer's machine as a *subrole*, scoped down per role.
Locally that is fine because the machine is single-tenant. The git history an
agent produces, however, is a durable artifact that outlives the session and
can be pushed to shared repositories, and today bot-bottle offers no
tamper-evidence over it:
- **No provenance.** Nothing ties a pushed commit to the bottle/activation that
actually produced it. `git-gate.user` name/email is forgeable and cosmetic
(ADR 0002); a commit could be produced anywhere.
- **No durable, portable record.** There is no host-side ledger that says "SHA
*X* was produced by agent *A* in bottle *B* on host *H* during interval
*[t0,t1]*, signed by key *K*," independent of any forge and surviving key
rotation.
## Goals / Success Criteria
- **Per-activation signing key.** A fresh Ed25519 keypair is minted host-side at
each activation; the private half lives only in the sidecar `ssh-agent`, never
in the bottle. Only `SSH_AUTH_SOCK` crosses the boundary.
- **Signed commits with no SHA divergence.** Commits produced in the bottle are
signed at commit time; the SHA the agent observes is the SHA that reaches the
upstream through the gate.
- **Gate rejects unsigned commits.** Before the gate forwards a push, every
newly-introduced commit (those not already reachable from the advertised
upstream refs) must verify against the activation public key; a push with any
unsigned or wrong-key new commit is rejected, loudly, with the offending SHA.
This is a **signature** check only — no author/committer matching.
- **Control-plane-owned attribution.** The orchestrator/control plane (sole
owner of `bot-bottle.db`, PRD 0070) recomputes each commit's object ID from the
bytes, verifies the embedded signature against the activation public key it
holds, and attaches activation metadata from its own state — accepting no SHA,
key, verdict, or metadata asserted by the gateway. No upstream fetch is
required.
- **Host is the source of truth.** The audit record binds each recomputed SHA to
the bottle, host, manifest, agent, activation interval, and retained public
key, and records the commit's claimed author/committer.
- **Verifiable after teardown.** The audit record retains the **full public
key, fingerprint, principal, and validity interval** — enough to regenerate an
allowed-signers file and run `git verify-commit` long after the activation
ends and the key is gone.
- **Reprovision-per-activation, fail-loud teardown.** The signing key is minted
once per activation (persists across restarts within that activation) and
discarded at teardown; deploy-key revocation continues to follow PRD 0048's
fail-loud discipline.
- **Push capability unchanged.** Forge access remains PRD 0048 deploy keys; no
new forge API dependency beyond 0048's existing deploy-key registration.
## Non-goals
- **Author/committer enforcement.** Explicitly out of scope for this PRD (issue
#423, comment #5590). The gate does not reject a commit for carrying a foreign
author or committer; those fields are recorded as claims. We rely on the
cross-forge audit store of signed commits and the authors recorded there. See
**Deferred: identity enforcement** for what a future add would look like.
- **Cryptographically-vouched author identity.** Not claimed — see **The
guarantee**.
- **Forge subuser accounts / provisioned API tokens / PAT minting.** Dropped.
Gitea's `POST /users/:name/tokens` requires Basic Auth *as the target user*
(an admin PAT cannot mint one for another user; the only server-side path is
the `gitea admin user generate-access-token` CLI), so a token-minting
bootstrap is a design in its own right (issue #423, comments #5518 / #5554).
This PRD needs no subrole API token, so that bootstrap problem does not arise.
- **Forge-side attribution surfaces.** No commit-status badges, no forge
"Verified" badge. The latter is doubly unsuitable: it renders dynamically
against a *currently registered* key (so it would lie the moment a
reprovisioned key is revoked), and on Gitea registering a signing key also
grants push. Attribution lives in the host record and local `git
verify-commit`, not the forge.
- **Non-Gitea forges, dashboard UI for orphan cleanup, mid-session rotation,
dirty-teardown reconciliation.** As before; a separate cleanup/sync pass
handles orphans left by a crash or discarded snapshot.
## Scope narrowing
This PRD started as "forge subroles" (forge subuser accounts + provisioned API
tokens + optional forge status posting + signing). Review (issue #423, comments
#5518#5590) narrowed it in two steps:
1. **Dropped the forge-account and API-token machinery** (#5518#5556):
the PAT bootstrap is not implementable as sketched (Basic-Auth-as-target-user
constraint); the signature never vouched the author anyway; and making the
host audit store the portable source of truth is a cleaner boundary that
removes the forge-specific token lifecycle and commit-status dependence.
2. **Dropped author/committer enforcement** (#5590): rely on the audit store of
signed commits and the authors recorded there; gate enforcement of the
identity fields is a possible future add, not part of this slice.
What remains is the core that stands on its own: **signed commits + a
host-owned, independently-verified audit record.** Forge *actors* (a per-bottle
account that comments/opens PRs) and *identity enforcement* (the gate rejecting a
foreign author/committer) are each candidate future PRDs.
## Design
### Trust boundary (control plane vs data plane)
git-gate is the **data plane**: it parses hostile bytes from inside the bottle
and forwards pushes. The orchestrator is the **control plane** and, per PRD
0070, is the sole owner of `bot-bottle.db`. These are different trust boundaries,
and the audit record must be anchored in the control plane:
- The gate performs a **synchronous pre-forward signature check** (below) and
can reject a push before it reaches the upstream. This is a data-plane gate on
what leaves the bottle, not the audit binding.
- The **control plane** takes the commit bytes to attribute (gateway-delivered
opaque bytes are fine), **recomputes the Git object ID**, **verifies the
embedded signature** against the activation public key it minted and holds, and
writes `attributed_commit` attaching metadata from its own state. It accepts
**no** gateway-supplied `verified` flag, claimed SHA, public key, or activation
identity.
The precise trust statement (issue #423, review by didericis-codex on d8362ec,
resolved in #5608): the row binds *these commit bytes / this recomputed SHA* to
*access to this activation's signing key*, and the control plane binds that key
to the recorded activation metadata. It does **not** assert forge observation or
that only the agent (not the signing sidecar) authored the commit — so this PRD
does **not** claim a compromised gateway cannot obtain an attribution row.
Because the sidecar holds the activation signing capability, a compromised
gateway *can* assemble and sign a commit and have it attributed to that
activation; what it cannot do is make the signature verify as a *different*
activation or choose the metadata the control plane records. That residual is
acceptable under the intended guarantee (#5607) and is why the guarantee is
worded as activation-key access, not agent-only authorship or upstream
publication. The gate therefore cannot stand in for host-side verification: the
control plane recomputes the object ID and verifies the signature itself rather
than trusting the gate's word.
### Identity model
Per **bottled agent** (agent definition ∘ sealed bottle), realized per
activation:
| Part | Value | Source | Role |
|------|-------|--------|------|
| Signing key | one Ed25519 keypair | minted host-side per activation | signs every commit; private half sidecar-only; the anchor of provenance |
| Author/committer | name + email | `git-gate.user` (PRD 0027 overlay) | written into commits and **recorded** as a claim; **not** enforced |
### Manifest surface
No new top-level keys and no `git-forge`/`forge-accounts` blocks. A single
opt-in flag under the existing `git-gate` key turns on per-activation signing;
`git-gate.user` (PRD 0027) supplies the author string as today.
```yaml
git-gate:
user: # PRD 0027 — author string; recorded, not enforced
name: didericis-claude
email: eric+claude@dideric.is
signing:
enabled: true # NEW — opt-in per-activation signing + audit
repos:
bot-bottle:
url: ssh://git@100.78.141.42:30009/didericis/bot-bottle.git
provisioned_key: # PRD 0048 — push capability, UNCHANGED
provider: gitea
token_env: GITEA_DEPLOY_TOKEN
host_key: "ssh-ed25519 AAAA..."
```
- `git-gate.signing.enabled: true` opts a bottle in. Without it, behavior is
exactly as today. There is **no `enforce` sub-key** — this PRD does not enforce
identity fields, so no knob is needed (and a knob that weakened a guarantee
was flagged as a contradiction in review).
- `git-gate.signing` is **bottle-only** (home-only policy), rejected at the
agent level with a clear pointer. `git-gate.user` keeps its PRD 0027
agent-overlay semantics.
### Signing: sign at commit time via a forwarded ssh-agent
The reason SHAs never diverge:
- The **sidecar** (the git-gate trust boundary) runs an `ssh-agent` holding the
short-lived signing private key.
- **Only `SSH_AUTH_SOCK`** is forwarded into the bottle — a bounded signing
capability, not the key.
- The provisioner writes the bottle `.gitconfig`:
```ini
[commit]
gpgsign = true
[gpg]
format = ssh
[user]
name = didericis-claude
email = eric+claude@dideric.is
signingkey = ssh-ed25519 AAAA... # activation signing PUBLIC key
```
- `git commit` asks the forwarded agent to sign; the signature is embedded at
object creation, so the agent-space SHA equals the pushed SHA. No transcoder,
no SHA translation table.
### Gate pre-forward signature check (data plane)
The gate already fetches from upstream before every `upload-pack` and mirrors
bidirectionally (PRD 0008). When `git-gate.signing.enabled` is set, after
gitleaks and before forwarding a push upstream:
1. **Compute the newly-introduced set.** Commits reachable from the pushed ref
tips but **not** reachable from any ref already advertised by the upstream
(which the gate knows because it fetches upstream first) — equivalent to
`git rev-list <new-tips> --not <all-known-upstream-refs>`. This excludes
pulled/merged existing history; a merge commit the bottle creates is itself
new and is checked, its already-upstream ancestors are not.
2. **Verify each new commit's signature** against the activation public key. A
commit that is unsigned or signed by any other key causes the push to be
**rejected** with the offending SHA.
3. No author/committer matching is performed.
This is a synchronous safety gate on what leaves the bottle; it is not the audit
record.
### Control-plane verification & recording
For each commit to attribute (the gate hands the control plane the commit bytes;
opaque gateway-delivered bytes are acceptable because nothing the gateway *says*
about them is trusted), the orchestrator/control plane:
1. **Recomputes the Git object ID** from the bytes itself. The stored `sha` is
this recomputed value, never a SHA the gateway claims.
2. **Verifies the embedded signature** against the activation public key it
minted and holds for that activation (via a generated allowed-signers file) —
ignoring any `verified` flag, key, or activation identity supplied by the
gateway.
3. Writes `attributed_commit` only for bytes that pass, stamping the activation
metadata (bottle/manifest/agent/host/interval) from its **own** state — not
from anything the gateway provides — and recording the commit's claimed
author/committer.
No upstream fetch is required: the guarantee is a byte↔activation-key binding, so
the object does not need to come from the forge (issue #423, #5608). Bytes that
do not verify against the activation key are **not** recorded as attributed (they
may be logged as an anomaly instead).
### Audit trail
The host SQLite store (PRD 0067, `~/.bot-bottle/bot-bottle.db`, owned by the
control plane per PRD 0070) records the signing-key lifecycle and per-commit
attribution. Retention is the **full public key, fingerprint, principal, and
validity interval** — enough to regenerate an allowed-signers file and verify
commits after teardown (issue #423, comment #5554, resolution 3). Never any
private key material.
```sql
CREATE TABLE bottled_agent_activation (
bottled_agent_slug TEXT NOT NULL,
activation_id TEXT NOT NULL, -- one per activation cycle
host TEXT NOT NULL,
manifest_digest TEXT NOT NULL, -- ties the record to the sealed manifest
agent TEXT NOT NULL,
signing_pubkey TEXT NOT NULL, -- full ssh-ed25519 public key (for verify-commit)
signing_fpr TEXT NOT NULL, -- SHA256:... fingerprint (stable handle)
principal TEXT NOT NULL, -- allowed-signers principal, e.g. the author email
valid_from TEXT NOT NULL,
valid_until TEXT, -- NULL while active; set at teardown
status TEXT NOT NULL, -- active | retired
PRIMARY KEY (bottled_agent_slug, activation_id)
);
CREATE TABLE attributed_commit (
sha TEXT NOT NULL, -- control-plane-RECOMPUTED object ID, not gateway-claimed
bottled_agent_slug TEXT NOT NULL,
activation_id TEXT NOT NULL,
repo TEXT NOT NULL,
author_name TEXT NOT NULL, -- CLAIMED, recorded as-is (not enforced)
author_email TEXT NOT NULL, -- CLAIMED
committer_name TEXT NOT NULL, -- CLAIMED
committer_email TEXT NOT NULL, -- CLAIMED
observed_at TEXT NOT NULL,
PRIMARY KEY (sha, repo)
);
```
Verification/allowed-signers generation is a stated part of the design: for a
given SHA, join `attributed_commit → bottled_agent_activation`, emit
`<principal> <signing_pubkey>` to a temporary allowed-signers file, and
`git verify-commit` (or `ssh-keygen -Y verify`) against it. The
`(pubkey, principal, valid_from/until)` tuple is exactly what that requires. The
recorded author/committer columns are the *claim*; a consumer that wants to know
"who says they wrote this" reads them, understanding they are unenforced.
### Credential lifecycle
Follows PRD 0048, minus the API-token kind (dropped):
- **Activation:** mint a fresh Ed25519 signing keypair; load the private half
into the sidecar `ssh-agent`; write the public half into `.gitconfig` and the
`bottled_agent_activation` row (`active`, `valid_from` set). Deploy keys are
provisioned exactly as PRD 0048. Minting is **per activation** (a restart
re-attaches the same key; a new activation mints a new key and retires the old
row), so frozen snapshots don't accumulate live keys.
- **Teardown (fail-loud):** revoke provisioned deploy keys via the forge API
(0048); discard the signing key from the sidecar agent and set the activation
row to `retired` with `valid_until`. The signing key was never on the forge,
so there is nothing to revoke there — only the local retire. Deploy-key
revocation failure halts teardown (0048); 404 = already-gone = success.
- **Dirty teardown** is assumed handled; a separate cleanup/sync pass reconciles
orphaned deploy keys.
## Deferred: identity enforcement
If a future PRD wants the gate to *enforce* that new commits carry the manifest
identity, the natural shape is: extend the gate pre-forward check to also require
each new commit's author **and** committer name/email to equal `git-gate.user`,
rejecting mismatches — with the same control-plane re-verification before
recording. This is deliberately left out now (issue #423, comment #5590); it is
noted so the door stays open and the current schema (which records the claimed
author/committer) already carries what such a check would compare against. Note
that even then the property would be gate-*enforced*, not signature-*vouched*; a
validating signing broker in front of the key would be required for the latter.
## Implementation chunks
1. **This PRD.** Sets the (narrowed) design.
2. **Manifest surface.** Add `git-gate.signing` (bottle-only; `enabled` only);
reject it at the agent level. Unit tests for parse/validation and the
agent-level rejection.
3. **Signing pipeline.** Sidecar `ssh-agent` provisioning; forward
`SSH_AUTH_SOCK` into the bottle across docker, smolmachines, macOS-container,
and firecracker backends; emit the `commit.gpgsign` / `gpg.format=ssh` /
`user.signingkey` gitconfig. Integration test: a bottle commit is
`verify-commit`-valid and its SHA is unchanged through the gate; the private
key is absent from the bottle.
4. **Gate pre-forward signature check.** Compute the newly-introduced set
(excluding upstream-reachable commits), verify each against the activation
key, reject unsigned/wrong-key with the offending SHA. Tests: unsigned
rejected; wrong-key rejected; pulled/merged upstream history passes; an
all-signed push succeeds. A foreign-author commit that is correctly signed
**passes the gate** (identity is not enforced here).
5. **Control-plane verification + audit.** `bottled_agent_activation` /
`attributed_commit` tables (PRD 0067 store, control-plane-owned per PRD 0070);
the control plane recomputes each commit's object ID and verifies the
signature before writing a row; retain full pubkey + fingerprint + principal +
validity interval; record claimed author/committer; allowed-signers generation
+ a post-teardown `verify-commit` helper. Tests: a gateway-claimed SHA/key/
verdict is ignored — the row's `sha` is the recomputed ID and bytes not signed
by the activation key produce **no** row.
6. **Docs.** Glossary entry ("per-bottle signed commits"); README manifest
section; ADR note that signing-enabled bottles gain signed *provenance* and a
host-owned audit record while authorship stays *claimed* (ADR 0002 unchanged).
## Testing strategy
- **Unit (must):** `git-gate.signing` parse/validation; agent-level `signing`
rejection.
- **Integration — signing (must):** end-to-end signed commit verifies with
`git verify-commit`; SHA observed in the bottle equals the SHA upstream; the
private key is absent from the bottle.
- **Integration — gate check (must):** unsigned rejected; wrong-key rejected;
the upstream-reachable exclusion (pull + merge human history and push a signed
merge); a correctly-signed foreign-author commit **passes** (no identity
enforcement); a clean all-signed push succeeds.
- **Control plane (must):** the control plane recomputes the object ID and
records a row for bytes genuinely signed by the activation key; a gateway-
supplied SHA/key/verdict is ignored (the stored `sha` is the recomputed value);
bytes signed by a foreign/invalid key produce **no** row.
- **Lifecycle:** activation mints the key and writes an `active` row; teardown
retires it (`valid_until`) and revokes deploy keys fail-loud; a restart
re-attaches the same key (no new row); a fresh activation mints a new key and
retires the old.
- **Post-teardown verification:** regenerate the allowed-signers file from a
`retired` row and confirm `verify-commit` still succeeds for an attributed SHA.
## Resolved: control-plane transport
Raised in review and **resolved** (issue #423, #5608): the commit object does not
need to come from the forge, and reading the gateway-owned mirror is no stronger
than accepting gateway-delivered bytes — both are fabricatable, and neither
matters because the control plane trusts nothing the gateway *asserts*. The
transport is therefore: the gate hands the control plane the raw commit bytes,
the control plane **recomputes the object ID** and **verifies the signature**
against the activation key, and stamps its own activation metadata. No upstream
fetch. This is exactly what makes the byte↔activation-key binding sound
regardless of transport.
## Open questions
- **Where the gate check slots into PRD 0008 ordering.** Modeled as a
pre-forward step after gitleaks; confirm it composes with the existing
access-hook / mirror ordering rather than needing a separate hook.
+3 -1
View File
@@ -19,7 +19,9 @@ _ORCH = "bot_bottle.backend.docker.orchestrator"
_RUN = f"{_ORCH}.run_docker"
_SLEEP = f"{_ORCH}.time.sleep"
_MONOTONIC = f"{_ORCH}.time.monotonic"
_TOKEN = f"{_ORCH}.host_orchestrator_token"
# The signing key is read through the shared provisioning contract (#476); patch
# its host-canonical key file read to keep it off the real host file.
_TOKEN = "bot_bottle.trust_domain.host_signing_key"
# The ABC's is_healthy probes /health via urllib in the lifecycle module.
_URLOPEN = "bot_bottle.orchestrator.lifecycle.urllib.request.urlopen"
+1 -1
View File
@@ -44,7 +44,7 @@ class TestEnsureRunning(unittest.TestCase):
vm = infra_vm.InfraVm(guest_ip="10.243.255.1", private_key=Path("/k"), vm=MagicMock())
with patch.object(infra_vm, "boot_vm", return_value=vm) as boot, \
patch.object(infra_vm, "push_secret") as push, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="host-key"), \
patch("bot_bottle.trust_domain.host_signing_key", return_value="host-key"), \
patch.object(orch, "is_running", return_value=False), \
patch.object(orch, "_ensure_registry_volume", return_value=Path("/reg")), \
patch.object(orch, "_wait_for_health"):
+2 -2
View File
@@ -33,7 +33,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def _run(self) -> list[str]:
run = Mock(return_value=_ok())
with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.bind_mount_spec.side_effect = _spec
mod.run_container_argv = run
@@ -65,7 +65,7 @@ class TestMacosOrchestratorRun(unittest.TestCase):
def test_start_failure_raises(self) -> None:
with patch(f"{_ORCH}.container_mod") as mod, \
patch(f"{_ORCH}.host_orchestrator_token", return_value="k"):
patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
mod.dns_server.return_value = "1.1.1.1"
mod.run_container_argv = Mock(return_value=_fail())
with self.assertRaises(OrchestratorStartError):
+133
View File
@@ -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()
+21
View File
@@ -82,5 +82,26 @@ class TestMintVerify(unittest.TestCase):
mint(ROLE_GATEWAY, "")
class TestCustomRoleSet(unittest.TestCase):
"""The `roles=` seam a trust domain other than the control plane uses (#476):
mint/verify are scoped to the passed role set, not the module default."""
_ROLES = frozenset({"host"})
def test_round_trips_a_role_in_the_custom_set(self) -> None:
tok = mint("host", _KEY, roles=self._ROLES)
self.assertEqual("host", verify(tok, _KEY, roles=self._ROLES))
def test_mint_rejects_a_role_outside_the_custom_set(self) -> None:
with self.assertRaises(ValueError):
mint(ROLE_CLI, _KEY, roles=self._ROLES)
def test_verify_rejects_a_role_outside_the_verifiers_set(self) -> None:
# A validly signed token whose role isn't in the verifier's set fails —
# this is what keeps one domain's key from asserting another's role.
tok = mint(ROLE_CLI, _KEY) # a control-plane `cli` token
self.assertIsNone(verify(tok, _KEY, roles=self._ROLES))
if __name__ == "__main__":
unittest.main()
+4 -2
View File
@@ -20,13 +20,15 @@ _URLOPEN = "bot_bottle.orchestrator.client.urllib.request.urlopen"
class TestHostAuthToken(unittest.TestCase):
def test_mints_a_cli_token_from_the_host_key(self) -> None:
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
# The CLI mints its `cli` token from the control-plane trust domain's
# host-canonical key (#476) — patch the key file read underneath it.
with patch("bot_bottle.trust_domain.host_signing_key",
return_value="signing-key"):
tok = _host_auth_token()
self.assertEqual(ROLE_CLI, verify(tok, "signing-key"))
def test_returns_empty_when_key_unreadable(self) -> None:
with patch("bot_bottle.orchestrator.client.host_orchestrator_token",
with patch("bot_bottle.trust_domain.host_signing_key",
side_effect=OSError("no host root")):
self.assertEqual("", _host_auth_token())
+105
View File
@@ -0,0 +1,105 @@
"""Unit: trust domains + the shared control-plane provisioning contract (#476)."""
from __future__ import annotations
import unittest
from unittest.mock import patch
from bot_bottle import orchestrator_auth
from bot_bottle.orchestrator_auth import ROLE_CLI, ROLE_GATEWAY
from bot_bottle.trust_domain import (
CONTROL_PLANE,
ControlPlaneProvisioning,
ProvisioningError,
TrustDomain,
)
# A second, unrelated domain — the shape #468's host controller would take: its
# own key file, its own role, its own env vars. Distinct from CONTROL_PLANE.
_HOST_CTRL = TrustDomain(
name="host-controller",
key_filename="host-controller-token",
roles=frozenset({"host"}),
key_env="BOT_BOTTLE_HOST_CONTROLLER_TOKEN",
token_env="BOT_BOTTLE_HOST_CONTROLLER_JWT",
)
class TestTrustDomainMintVerify(unittest.TestCase):
def test_mint_verify_round_trips_within_a_domain(self) -> None:
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = CONTROL_PLANE.mint(ROLE_GATEWAY)
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
def test_mint_rejects_a_role_outside_the_domain(self) -> None:
# `host` is a valid role in _HOST_CTRL but not in the control plane —
# the control-plane key must refuse to mint it.
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
with self.assertRaises(ValueError):
CONTROL_PLANE.mint("host")
def test_a_custom_role_set_verifies_its_own_role(self) -> None:
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = _HOST_CTRL.mint("host")
self.assertEqual("host", _HOST_CTRL.verify(tok, "k"))
def test_key_from_env_reads_the_domains_env_var(self) -> None:
self.assertEqual(
"secret", CONTROL_PLANE.key_from_env({CONTROL_PLANE.key_env: " secret "}))
self.assertEqual("", CONTROL_PLANE.key_from_env({}))
class TestDomainBoundary(unittest.TestCase):
"""The #476/#468 invariant: two domains, two keys, two role sets — one
domain's key can neither mint nor verify the other's tokens."""
def test_a_control_plane_token_does_not_verify_under_another_domains_key(
self,
) -> None:
# Even with an IDENTICAL underlying key, a `cli` token minted under the
# control-plane domain must not verify as a role in the host-controller
# domain — the role isn't in that domain's set.
cli_tok = orchestrator_auth.mint(ROLE_CLI, "shared-bytes")
self.assertIsNone(_HOST_CTRL.verify(cli_tok, "shared-bytes"))
def test_distinct_keys_do_not_cross_verify(self) -> None:
# The realistic case: distinct host-canonical keys per domain. A token
# signed by one key never verifies under the other.
host_tok = orchestrator_auth.mint(
"host", "host-ctrl-key", roles=_HOST_CTRL.roles)
self.assertIsNone(_HOST_CTRL.verify(host_tok, "control-plane-key"))
self.assertEqual("host", _HOST_CTRL.verify(host_tok, "host-ctrl-key"))
class TestControlPlaneProvisioning(unittest.TestCase):
def test_orchestrator_key_returns_the_canonical_key(self) -> None:
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="key"):
self.assertEqual("key", prov.orchestrator_key())
def test_orchestrator_key_fail_closes_when_empty(self) -> None:
# Invariant 4: the orchestrator must never start without a key — it would
# run OPEN and grant every caller that reaches it full `cli`. There is no
# topology opt-out: a separate host does not stop the gateway (or any
# other caller) from reaching the control-plane listener.
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value=""):
with self.assertRaises(ProvisioningError):
prov.orchestrator_key()
def test_gateway_token_is_a_verifiable_gateway_role_token(self) -> None:
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = prov.gateway_token()
self.assertEqual(ROLE_GATEWAY, CONTROL_PLANE.verify(tok, "k"))
def test_gateway_token_cannot_be_reused_as_cli(self) -> None:
# The data plane's token is `gateway`-scoped: it never carries `cli`.
prov = ControlPlaneProvisioning()
with patch("bot_bottle.trust_domain.host_signing_key", return_value="k"):
tok = prov.gateway_token()
self.assertNotEqual(ROLE_CLI, CONTROL_PLANE.verify(tok, "k"))
if __name__ == "__main__":
unittest.main()