Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bdca1c8bea | |||
| 31cde11b0d | |||
| c41751f3b9 | |||
| e2422c20a0 | |||
| de71533a17 | |||
| 88c4f61901 | |||
| c666eaa63f | |||
| 83eb9e4041 | |||
| 33333ac4d9 | |||
| 4d56f515bc |
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist and request-body DLP scanner; DoH and arbitrary hosts blocked by default.
|
- **Per-bottle egress allowlist** — TLS-bumped HTTP/HTTPS chokepoint with a per-manifest host allowlist; per-route path/method/header `matches` filtering; outbound DLP scanning for known tokens and secrets, inbound DLP scanning for prompt-injection attempts; DoH and arbitrary hosts blocked by default.
|
||||||
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
- **Tokens the agent never sees** — host secrets live in a sidecar; the agent dials `http://sidecar:9099/<path>` and the proxy strips inbound `Authorization` and injects the real token before forwarding. `printenv` in the agent shows proxy URLs only.
|
||||||
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
- **Gitleaks-scanned push (git-gate)** — `bottle.git` remotes route through a per-bottle `git daemon` that gitleaks-scans incoming refs pre-receive and forwards clean refs upstream over SSH. The agent never holds the upstream credential.
|
||||||
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
- **Manifest-scoped skills + secrets** — each bottle declares its skills, env, git identity, remotes, and egress routes; unknown keys die at load.
|
||||||
@@ -106,8 +106,15 @@ egress:
|
|||||||
routes:
|
routes:
|
||||||
- host: gitea.dideric.is
|
- host: gitea.dideric.is
|
||||||
auth:
|
auth:
|
||||||
scheme: token
|
scheme: token # Bearer | token
|
||||||
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
token_ref: BOT_BOTTLE_GITEA_TOKEN
|
||||||
|
matches: # optional — restrict to specific paths/methods/headers
|
||||||
|
- paths:
|
||||||
|
- {type: prefix, value: /api/v1/}
|
||||||
|
methods: [GET, POST, PATCH, DELETE]
|
||||||
|
dlp: # optional — per-route detector overrides (default: all on)
|
||||||
|
outbound_detectors: [token_patterns, known_secrets]
|
||||||
|
inbound_detectors: false # disable response scanning for this host
|
||||||
---
|
---
|
||||||
|
|
||||||
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
The `gitea-dev` bottle. Provider auth via the inherited Claude route;
|
||||||
@@ -126,6 +133,23 @@ skills:
|
|||||||
You help maintain Gitea-hosted projects.
|
You help maintain Gitea-hosted projects.
|
||||||
````
|
````
|
||||||
|
|
||||||
|
**Egress route fields:**
|
||||||
|
|
||||||
|
| Field | Required | Description |
|
||||||
|
|---|---|---|
|
||||||
|
| `host` | yes | Hostname to allowlist. One entry per host. |
|
||||||
|
| `role` | no | Reserved for future use. The key is recognised but any value is currently rejected at load. Provider auth routes (e.g. Claude's `api.anthropic.com`) are injected automatically from `agent_provider.auth_token`, not via `role`. |
|
||||||
|
| `auth.scheme` | when `auth` present | `Bearer` or `token`. Injected by the proxy; the agent never sees the value. |
|
||||||
|
| `auth.token_ref` | when `auth` present | Env-var name holding the secret on the host. |
|
||||||
|
| `matches` | no | Array of `{paths, methods, headers}` filters. A request must match at least one entry (if any are given) to be forwarded. |
|
||||||
|
| `matches[].paths` | no | Array of `{type, value}`. `type` is `prefix` (default), `exact`, or `regex`. |
|
||||||
|
| `matches[].methods` | no | Array of HTTP method strings, e.g. `[GET, POST]`. |
|
||||||
|
| `matches[].headers` | no | Array of `{name, value, type}`. `type` is `exact` (default) or `regex`. |
|
||||||
|
| `dlp` | no | Per-route DLP overrides. Omit to use defaults (all detectors on). |
|
||||||
|
| `dlp.outbound_detectors` | no | `false` disables outbound scanning; list restricts to named detectors (`token_patterns`, `known_secrets`). |
|
||||||
|
| `dlp.inbound_detectors` | no | `false` disables inbound scanning; list restricts to named detectors (`naive_injection_detection`). |
|
||||||
|
| `git.fetch` | no | `true` permits smart HTTP clone/fetch (`git-upload-pack`) for this host. Push (`git-receive-pack`) remains blocked. |
|
||||||
|
|
||||||
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
More examples in `examples/`. Full design lives under `docs/prds/`; the trust-boundary rationale is in `docs/prds/0011-per-file-md-manifest.md`.
|
||||||
|
|
||||||
## Trademarks
|
## Trademarks
|
||||||
|
|||||||
@@ -207,8 +207,7 @@ class AgentProvider(ABC):
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Register the per-bottle supervise sidecar as an MCP server
|
"""Register the per-bottle supervise sidecar as an MCP server
|
||||||
in the provider's in-guest config. Called by the backend after
|
in the provider's in-guest config. Called by the backend after
|
||||||
the supervise sidecar is reachable. No-op when
|
the supervise sidecar is reachable."""
|
||||||
`plan.supervise_plan is None`."""
|
|
||||||
|
|
||||||
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
def provision_ca(self, bottle: "Bottle", plan: "BottlePlan") -> None:
|
||||||
"""Install the egress MITM CA into the agent's trust store.
|
"""Install the egress MITM CA into the agent's trust store.
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ class BottlePlan(ABC):
|
|||||||
over a published host port)."""
|
over a published host port)."""
|
||||||
return "git"
|
return "git"
|
||||||
egress_plan: EgressPlan
|
egress_plan: EgressPlan
|
||||||
supervise_plan: SupervisePlan | None
|
supervise_plan: SupervisePlan
|
||||||
agent_provision: AgentProvisionPlan
|
agent_provision: AgentProvisionPlan
|
||||||
|
|
||||||
@property
|
@property
|
||||||
@@ -332,7 +332,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
)
|
)
|
||||||
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
agent_provision_plan = merge_provision_env_vars(agent_provision_plan)
|
||||||
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
egress_plan = prepare_egress(manifest_bottle, slug, agent_provision_plan)
|
||||||
supervise_plan = prepare_supervise(manifest_bottle, slug)
|
supervise_plan = prepare_supervise(slug)
|
||||||
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
git_gate_plan = prepare_git_gate(manifest_bottle, slug)
|
||||||
|
|
||||||
return self._resolve_plan(
|
return self._resolve_plan(
|
||||||
@@ -405,7 +405,7 @@ class BottleBackend(ABC, Generic[PlanT, CleanupT]):
|
|||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
stage_dir: Path) -> PlanT:
|
stage_dir: Path) -> PlanT:
|
||||||
"""Backend-specific plan resolution: image/container names,
|
"""Backend-specific plan resolution: image/container names,
|
||||||
env-file, prompt-file, proxy plan, runtime detection. Called by
|
env-file, prompt-file, proxy plan, runtime detection. Called by
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
@@ -94,8 +94,6 @@ class DockerBottleBackend(BottleBackend["DockerBottlePlan", "DockerBottleCleanup
|
|||||||
"""Docker bottles reach the supervise sidecar via the
|
"""Docker bottles reach the supervise sidecar via the
|
||||||
compose-network alias `supervise:9100`. No per-bottle URL
|
compose-network alias `supervise:9100`. No per-bottle URL
|
||||||
plumbing needed; the alias resolves inside the bridge."""
|
plumbing needed; the alias resolves inside the bridge."""
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return ""
|
|
||||||
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
return f"http://{SUPERVISE_HOSTNAME}:{SUPERVISE_PORT}/"
|
||||||
|
|
||||||
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
def prepare_cleanup(self) -> DockerBottleCleanupPlan:
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ Conditional services follow the plan content:
|
|||||||
- agent + sidecars bundle: always.
|
- agent + sidecars bundle: always.
|
||||||
- git-gate: iff plan.git_gate_plan.upstreams.
|
- git-gate: iff plan.git_gate_plan.upstreams.
|
||||||
- egress: iff plan.egress_plan.routes.
|
- egress: iff plan.egress_plan.routes.
|
||||||
- supervise: iff plan.supervise_plan is not None.
|
- supervise: always (every bottle is supervised, issue #249).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -119,13 +119,11 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
image, all daemons under a Python init supervisor.
|
image, all daemons under a Python init supervisor.
|
||||||
|
|
||||||
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
Daemon subset narrows via `BOT_BOTTLE_SIDECAR_DAEMONS` env.
|
||||||
egress is always present; git-gate / supervise are conditional.
|
egress and supervise are always present; git-gate is conditional.
|
||||||
"""
|
"""
|
||||||
daemons: list[str] = ["egress"]
|
daemons: list[str] = ["egress", "supervise"]
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
daemons.append("git-gate")
|
daemons.append("git-gate")
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
|
|
||||||
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
env: list[str] = [f"BOT_BOTTLE_SIDECAR_DAEMONS={','.join(daemons)}"]
|
||||||
volumes: list[dict[str, Any]] = []
|
volumes: list[dict[str, Any]] = []
|
||||||
@@ -160,24 +158,21 @@ def _sidecar_bundle_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
|
|
||||||
# --- supervise ----------------------------------------------------
|
# --- supervise ----------------------------------------------------
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
if sp is not None:
|
env += [
|
||||||
env += [
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
]
|
||||||
]
|
volumes.append({
|
||||||
volumes.append({
|
"type": "bind",
|
||||||
"type": "bind",
|
"source": str(sp.queue_dir),
|
||||||
"source": str(sp.queue_dir),
|
"target": QUEUE_DIR_IN_CONTAINER,
|
||||||
"target": QUEUE_DIR_IN_CONTAINER,
|
"read_only": False,
|
||||||
"read_only": False,
|
})
|
||||||
})
|
|
||||||
|
|
||||||
internal_aliases = [EGRESS_HOSTNAME]
|
internal_aliases = [EGRESS_HOSTNAME, SUPERVISE_HOSTNAME]
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
internal_aliases.append(GIT_GATE_HOSTNAME)
|
internal_aliases.append(GIT_GATE_HOSTNAME)
|
||||||
if sp is not None:
|
|
||||||
internal_aliases.append(SUPERVISE_HOSTNAME)
|
|
||||||
|
|
||||||
service: dict[str, Any] = {
|
service: dict[str, Any] = {
|
||||||
"image": SIDECAR_BUNDLE_IMAGE,
|
"image": SIDECAR_BUNDLE_IMAGE,
|
||||||
@@ -231,14 +226,10 @@ def _agent_service(plan: DockerBottlePlan) -> dict[str, Any]:
|
|||||||
if plan.use_runsc:
|
if plan.use_runsc:
|
||||||
service["runtime"] = "runsc"
|
service["runtime"] = "runsc"
|
||||||
|
|
||||||
volumes: list[dict[str, Any]] = []
|
service["volumes"] = [_bind(
|
||||||
if plan.supervise_plan is not None:
|
plan.supervise_plan.current_config_dir,
|
||||||
volumes.append(_bind(
|
CURRENT_CONFIG_DIR_IN_AGENT,
|
||||||
plan.supervise_plan.current_config_dir,
|
)]
|
||||||
CURRENT_CONFIG_DIR_IN_AGENT,
|
|
||||||
))
|
|
||||||
if volumes:
|
|
||||||
service["volumes"] = volumes
|
|
||||||
|
|
||||||
# The init supervisor inside the bundle owns intra-bundle
|
# The init supervisor inside the bundle owns intra-bundle
|
||||||
# daemon ordering, so the agent only waits for the bundle
|
# daemon ordering, so the agent only waits for the bundle
|
||||||
@@ -254,12 +245,9 @@ def _agent_proxy_url(plan: DockerBottlePlan) -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
def _agent_no_proxy(plan: DockerBottlePlan) -> str:
|
||||||
"""NO_PROXY for the agent: loopback always; supervise hostname
|
"""NO_PROXY for the agent: loopback plus the supervise hostname
|
||||||
when the supervise sidecar is up (MCP long-poll must bypass
|
(MCP long-poll must bypass the egress proxy)."""
|
||||||
the egress proxy)."""
|
hosts = ["localhost", "127.0.0.1", SUPERVISE_HOSTNAME]
|
||||||
hosts = ["localhost", "127.0.0.1"]
|
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
hosts.append(SUPERVISE_HOSTNAME)
|
|
||||||
return ",".join(hosts)
|
return ",".join(hosts)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -130,12 +130,10 @@ def launch(
|
|||||||
mitmproxy_ca_host_path=egress_ca_host,
|
mitmproxy_ca_host_path=egress_ca_host,
|
||||||
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
mitmproxy_ca_cert_only_host_path=egress_ca_cert_only,
|
||||||
)
|
)
|
||||||
supervise_plan = plan.supervise_plan
|
supervise_plan = dataclasses.replace(
|
||||||
if supervise_plan is not None:
|
plan.supervise_plan,
|
||||||
supervise_plan = dataclasses.replace(
|
internal_network=internal_network,
|
||||||
supervise_plan,
|
)
|
||||||
internal_network=internal_network,
|
|
||||||
)
|
|
||||||
plan = dataclasses.replace(
|
plan = dataclasses.replace(
|
||||||
plan,
|
plan,
|
||||||
git_gate_plan=git_gate_plan,
|
git_gate_plan=git_gate_plan,
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ def resolve_plan(
|
|||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ class MacosContainerBottleBackend(
|
|||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
|
|||||||
@@ -222,9 +222,7 @@ def _stamp_agent_urls(
|
|||||||
sidecar_ip: str,
|
sidecar_ip: str,
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
proxy_url = f"http://{sidecar_ip}:{EGRESS_PORT}"
|
||||||
supervise_url = ""
|
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
supervise_url = f"http://{sidecar_ip}:{SUPERVISE_PORT}/"
|
|
||||||
git_gate_url = ""
|
git_gate_url = ""
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
|
git_gate_url = f"http://{sidecar_ip}:{_GIT_HTTP_PORT}"
|
||||||
@@ -341,11 +339,9 @@ def _sidecar_dns() -> str:
|
|||||||
|
|
||||||
|
|
||||||
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
def _sidecar_daemons(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
||||||
daemons = ["egress"]
|
daemons = ["egress", "supervise"]
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
daemons += ["git-gate", "git-http"]
|
daemons += ["git-gate", "git-http"]
|
||||||
if plan.supervise_plan is not None:
|
|
||||||
daemons.append("supervise")
|
|
||||||
return tuple(daemons)
|
return tuple(daemons)
|
||||||
|
|
||||||
|
|
||||||
@@ -355,12 +351,11 @@ def _sidecar_env_entries(plan: MacosContainerBottlePlan) -> tuple[str, ...]:
|
|||||||
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
|
env.extend(sorted(plan.egress_plan.token_env_map.keys()))
|
||||||
if plan.git_gate_plan.upstreams:
|
if plan.git_gate_plan.upstreams:
|
||||||
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
env.append(f"BOT_BOTTLE_GIT_GATE_READY_FILE={_GIT_GATE_READY_FILE}")
|
||||||
if plan.supervise_plan is not None:
|
env += [
|
||||||
env += [
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
]
|
||||||
]
|
|
||||||
return tuple(env)
|
return tuple(env)
|
||||||
|
|
||||||
|
|
||||||
@@ -383,8 +378,7 @@ def _sidecar_mounts(
|
|||||||
))
|
))
|
||||||
|
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
if sp is not None:
|
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||||
mounts.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
|
||||||
|
|
||||||
return tuple(mounts)
|
return tuple(mounts)
|
||||||
|
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ def resolve_plan(
|
|||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
|
|||||||
@@ -92,11 +92,9 @@ def prepare_egress(
|
|||||||
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
return Egress().prepare(bottle, slug, egress_dir, provision.egress_routes)
|
||||||
|
|
||||||
|
|
||||||
def prepare_supervise(bottle: ManifestBottle, slug: str) -> SupervisePlan | None:
|
def prepare_supervise(slug: str) -> SupervisePlan:
|
||||||
"""Prepare the supervise sidecar state dir. Returns None when
|
"""Prepare the supervise sidecar state dir. Every bottle is
|
||||||
bottle.supervise is falsy."""
|
supervised (issue #249), so this always returns a plan."""
|
||||||
if not bottle.supervise:
|
|
||||||
return None
|
|
||||||
supervise_dir = supervise_state_dir(slug)
|
supervise_dir = supervise_state_dir(slug)
|
||||||
supervise_dir.mkdir(parents=True, exist_ok=True)
|
supervise_dir.mkdir(parents=True, exist_ok=True)
|
||||||
return Supervise().prepare(slug, supervise_dir)
|
return Supervise().prepare(slug, supervise_dir)
|
||||||
|
|||||||
@@ -62,7 +62,7 @@ class SmolmachinesBottleBackend(
|
|||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
return _resolve_plan.resolve_plan(
|
return _resolve_plan.resolve_plan(
|
||||||
|
|||||||
@@ -206,12 +206,10 @@ def _discover_urls(
|
|||||||
)
|
)
|
||||||
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
agent_git_gate_host = f"{loopback_ip}:{git_gate_host_port}"
|
||||||
|
|
||||||
agent_supervise_url = ""
|
supervise_host_port = _bundle.bundle_host_port(
|
||||||
if plan.supervise_plan is not None:
|
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
|
||||||
supervise_host_port = _bundle.bundle_host_port(
|
)
|
||||||
plan.slug, _SUPERVISE_PORT, host_ip=loopback_ip,
|
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
||||||
)
|
|
||||||
agent_supervise_url = f"http://{loopback_ip}:{supervise_host_port}/"
|
|
||||||
|
|
||||||
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
existing_no_proxy = plan.guest_env.get("NO_PROXY", "localhost,127.0.0.1")
|
||||||
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
no_proxy = f"{existing_no_proxy},{loopback_ip}"
|
||||||
@@ -299,15 +297,14 @@ def _bundle_launch_spec(
|
|||||||
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
"""Build a BundleLaunchSpec from the resolved inner Plans.
|
||||||
|
|
||||||
Daemons in the CSV:
|
Daemons in the CSV:
|
||||||
- egress is always present.
|
- egress and supervise are always present.
|
||||||
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
|
- git-gate + git-http are conditional on plan.git_gate_plan.upstreams.
|
||||||
- supervise is conditional on plan.supervise_plan.
|
|
||||||
|
|
||||||
Env + volumes are the union of the sidecar daemons' needs, with
|
Env + volumes are the union of the sidecar daemons' needs, with
|
||||||
daemon-private values only (HTTPS_PROXY is scoped to the
|
daemon-private values only (HTTPS_PROXY is scoped to the
|
||||||
egress process by egress_entrypoint.sh — see PRD 0024's bundle
|
egress process by egress_entrypoint.sh — see PRD 0024's bundle
|
||||||
bind-address PR)."""
|
bind-address PR)."""
|
||||||
daemons: list[str] = ["egress"]
|
daemons: list[str] = ["egress", "supervise"]
|
||||||
env: list[str] = []
|
env: list[str] = []
|
||||||
volumes: list[tuple[str, str, bool]] = []
|
volumes: list[tuple[str, str, bool]] = []
|
||||||
|
|
||||||
@@ -347,23 +344,19 @@ def _bundle_launch_spec(
|
|||||||
|
|
||||||
# --- supervise --------------------------------------------
|
# --- supervise --------------------------------------------
|
||||||
sp = plan.supervise_plan
|
sp = plan.supervise_plan
|
||||||
if sp is not None:
|
env += [
|
||||||
daemons.append("supervise")
|
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
||||||
env += [
|
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
||||||
f"SUPERVISE_BOTTLE_SLUG={plan.slug}",
|
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
||||||
f"SUPERVISE_QUEUE_DIR={QUEUE_DIR_IN_CONTAINER}",
|
]
|
||||||
f"SUPERVISE_PORT={SUPERVISE_PORT}",
|
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
||||||
]
|
|
||||||
volumes.append((str(sp.queue_dir), QUEUE_DIR_IN_CONTAINER, False))
|
|
||||||
|
|
||||||
# Container ports the agent reaches from the smolvm guest —
|
# Container ports the agent reaches from the smolvm guest —
|
||||||
# published on host loopback so the guest can dial via TSI +
|
# published on host loopback so the guest can dial via TSI +
|
||||||
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
|
# macOS networking. Egress is always the agent's HTTP/HTTPS proxy.
|
||||||
ports_to_publish: list[int] = [_EGRESS_PORT]
|
ports_to_publish: list[int] = [_EGRESS_PORT, _SUPERVISE_PORT]
|
||||||
if gp.upstreams:
|
if gp.upstreams:
|
||||||
ports_to_publish.append(_GIT_HTTP_PORT)
|
ports_to_publish.append(_GIT_HTTP_PORT)
|
||||||
if sp is not None:
|
|
||||||
ports_to_publish.append(_SUPERVISE_PORT)
|
|
||||||
|
|
||||||
return _bundle.BundleLaunchSpec(
|
return _bundle.BundleLaunchSpec(
|
||||||
slug=plan.slug,
|
slug=plan.slug,
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ def resolve_plan(
|
|||||||
resolved_env: ResolvedEnv,
|
resolved_env: ResolvedEnv,
|
||||||
agent_provision_plan: AgentProvisionPlan,
|
agent_provision_plan: AgentProvisionPlan,
|
||||||
egress_plan: EgressPlan,
|
egress_plan: EgressPlan,
|
||||||
supervise_plan: SupervisePlan | None,
|
supervise_plan: SupervisePlan,
|
||||||
git_gate_plan: GitGatePlan,
|
git_gate_plan: GitGatePlan,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
) -> SmolmachinesBottlePlan:
|
) -> SmolmachinesBottlePlan:
|
||||||
|
|||||||
@@ -68,7 +68,8 @@ class BundleLaunchSpec:
|
|||||||
image: str = SIDECAR_BUNDLE_IMAGE
|
image: str = SIDECAR_BUNDLE_IMAGE
|
||||||
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
|
# Daemon subset CSV for BOT_BOTTLE_SIDECAR_DAEMONS. The
|
||||||
# supervisor inside the bundle reads it to skip
|
# supervisor inside the bundle reads it to skip
|
||||||
# bottle-irrelevant daemons (e.g. supervise=False bottles).
|
# bottle-irrelevant daemons (e.g. git-gate when a bottle
|
||||||
|
# declares no upstreams).
|
||||||
daemons_csv: str = "egress"
|
daemons_csv: str = "egress"
|
||||||
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
|
# Plain "KEY=VALUE" strings + "KEY" bare names (the bare-name
|
||||||
# form inherits the value from the docker-run subprocess env,
|
# form inherits the value from the docker-run subprocess env,
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ from ..supervise import (
|
|||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_ALLOW,
|
TOOL_ALLOW,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
render_diff,
|
render_diff,
|
||||||
@@ -140,6 +141,8 @@ def _suffix_for_tool(tool: str) -> str:
|
|||||||
return ".dockerfile"
|
return ".dockerfile"
|
||||||
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
if tool in (TOOL_ALLOW, TOOL_EGRESS_BLOCK):
|
||||||
return ".yaml"
|
return ".yaml"
|
||||||
|
if tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
return ".txt"
|
||||||
return ".txt"
|
return ".txt"
|
||||||
|
|
||||||
|
|
||||||
@@ -201,6 +204,23 @@ def reject(qp: QueuedProposal, *, reason: str) -> None:
|
|||||||
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
_write_audit(qp, action=STATUS_REJECTED, notes=reason, diff_before="", diff_after="")
|
||||||
|
|
||||||
|
|
||||||
|
def _approve_from_tui(
|
||||||
|
stdscr: "curses._CursesWindow", # type: ignore
|
||||||
|
qp: QueuedProposal,
|
||||||
|
*,
|
||||||
|
final_file: str | None = None,
|
||||||
|
notes: str = "",
|
||||||
|
) -> str:
|
||||||
|
"""Approve from curses, prompting for any tool-specific audit note."""
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW and final_file is None:
|
||||||
|
notes = _prompt(stdscr, "allow reason (test fixture/false positive): ")
|
||||||
|
if not notes:
|
||||||
|
return "approve aborted (empty reason)"
|
||||||
|
approve(qp, final_file=final_file, notes=notes)
|
||||||
|
verb = "modified+approved" if final_file is not None else "approved"
|
||||||
|
return _approval_status(qp, verb)
|
||||||
|
|
||||||
|
|
||||||
def _write_audit(
|
def _write_audit(
|
||||||
qp: QueuedProposal,
|
qp: QueuedProposal,
|
||||||
*,
|
*,
|
||||||
@@ -384,18 +404,22 @@ def _main_loop(stdscr: "curses._CursesWindow") -> None: # type: ignore
|
|||||||
_detail_view(stdscr, qp, green_attr=green_attr)
|
_detail_view(stdscr, qp, green_attr=green_attr)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
status_line = _approve_from_tui(stdscr, qp)
|
||||||
status_line = _approval_status(qp, "approved")
|
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
status_line = "modify unavailable for gitleaks-allow"
|
||||||
|
continue
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is None:
|
if edited is None:
|
||||||
status_line = "modify aborted (no change)"
|
status_line = "modify aborted (no change)"
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
status_line = _approve_from_tui(
|
||||||
status_line = _approval_status(qp, "modified+approved")
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError as e:
|
except ApplyError as e:
|
||||||
status_line = f"apply failed: {e}"
|
status_line = f"apply failed: {e}"
|
||||||
elif key == ord("r"):
|
elif key == ord("r"):
|
||||||
@@ -493,15 +517,20 @@ def _detail_view(
|
|||||||
offset = max(0, len(lines) - 1)
|
offset = max(0, len(lines) - 1)
|
||||||
elif key == ord("a"):
|
elif key == ord("a"):
|
||||||
try:
|
try:
|
||||||
approve(qp)
|
_approve_from_tui(stdscr, qp)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
elif key == ord("m"):
|
elif key == ord("m"):
|
||||||
|
if qp.proposal.tool == TOOL_GITLEAKS_ALLOW:
|
||||||
|
return
|
||||||
edited = _modify(stdscr, qp)
|
edited = _modify(stdscr, qp)
|
||||||
if edited is not None:
|
if edited is not None:
|
||||||
try:
|
try:
|
||||||
approve(qp, final_file=edited, notes="operator modified before approving")
|
_approve_from_tui(
|
||||||
|
stdscr, qp, final_file=edited,
|
||||||
|
notes="operator modified before approving",
|
||||||
|
)
|
||||||
except ApplyError:
|
except ApplyError:
|
||||||
pass
|
pass
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -291,8 +291,6 @@ class ClaudeAgentProvider(AgentProvider):
|
|||||||
|
|
||||||
Failure is logged but not fatal — the bottle still works without
|
Failure is logged but not fatal — the bottle still works without
|
||||||
the entry; the operator can register it manually."""
|
the entry; the operator can register it manually."""
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return
|
|
||||||
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
info(f"registering supervise MCP server in agent claude config → {supervise_url}")
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"claude mcp add --scope user --transport http "
|
f"claude mcp add --scope user --transport http "
|
||||||
|
|||||||
@@ -257,8 +257,6 @@ class CodexAgentProvider(AgentProvider):
|
|||||||
|
|
||||||
Mirrors the Claude provider's `claude mcp add` flow — failure
|
Mirrors the Claude provider's `claude mcp add` flow — failure
|
||||||
is logged but not fatal."""
|
is logged but not fatal."""
|
||||||
if plan.supervise_plan is None:
|
|
||||||
return
|
|
||||||
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
info(f"registering supervise MCP server in agent codex config → {supervise_url}")
|
||||||
r = bottle.exec(
|
r = bottle.exec(
|
||||||
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
|
f"codex mcp add {_SUPERVISE_MCP_NAME} --url "
|
||||||
|
|||||||
@@ -247,6 +247,164 @@ cat > "$refs_file"
|
|||||||
|
|
||||||
zero=0000000000000000000000000000000000000000
|
zero=0000000000000000000000000000000000000000
|
||||||
|
|
||||||
|
supervise_gitleaks_allow() {
|
||||||
|
log_opts=$1
|
||||||
|
ref=$2
|
||||||
|
report_file=$(mktemp)
|
||||||
|
if ! gitleaks git \
|
||||||
|
--log-opts="$log_opts" \
|
||||||
|
--no-banner \
|
||||||
|
--redact \
|
||||||
|
--ignore-gitleaks-allow \
|
||||||
|
--report-format=json \
|
||||||
|
--report-path="$report_file" \
|
||||||
|
--exit-code 0 \
|
||||||
|
1>&2; then
|
||||||
|
rm -f "$report_file"
|
||||||
|
echo "git-gate: gitleaks inline-suppression scan failed for $ref" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
proposal_id=$(
|
||||||
|
GITLEAKS_ALLOW_REF="$ref" python3 - "$report_file" <<'PY'
|
||||||
|
import datetime
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import uuid
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
report_path = Path(sys.argv[1])
|
||||||
|
queue_dir = os.environ.get("SUPERVISE_QUEUE_DIR", "")
|
||||||
|
slug = os.environ.get("SUPERVISE_BOTTLE_SLUG", "")
|
||||||
|
if not queue_dir or not slug:
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw = json.loads(report_path.read_text() or "[]")
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
sys.exit(3)
|
||||||
|
if not isinstance(raw, list):
|
||||||
|
sys.exit(3)
|
||||||
|
if not raw:
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
|
ref = os.environ.get("GITLEAKS_ALLOW_REF", "")
|
||||||
|
lines = [
|
||||||
|
"gitleaks inline suppression requires supervisor approval",
|
||||||
|
f"ref: {ref}",
|
||||||
|
"",
|
||||||
|
]
|
||||||
|
for i, finding in enumerate(raw, 1):
|
||||||
|
if not isinstance(finding, dict):
|
||||||
|
continue
|
||||||
|
file_path = finding.get("File", "")
|
||||||
|
line_no = finding.get("StartLine", finding.get("Line", ""))
|
||||||
|
rule_id = finding.get("RuleID", "")
|
||||||
|
commit = finding.get("Commit", "")
|
||||||
|
line = finding.get("Line", "")
|
||||||
|
lines.extend([
|
||||||
|
f"finding {i}:",
|
||||||
|
f" file: {file_path}",
|
||||||
|
f" line: {line_no}",
|
||||||
|
f" rule: {rule_id}",
|
||||||
|
f" commit: {commit}",
|
||||||
|
f" code: {line}",
|
||||||
|
"",
|
||||||
|
])
|
||||||
|
|
||||||
|
payload = "\n".join(lines).rstrip() + "\n"
|
||||||
|
proposal_id = str(uuid.uuid4())
|
||||||
|
proposal = {
|
||||||
|
"id": proposal_id,
|
||||||
|
"bottle_slug": slug,
|
||||||
|
"tool": "gitleaks-allow",
|
||||||
|
"proposed_file": payload,
|
||||||
|
"justification": (
|
||||||
|
"git-gate found gitleaks findings hidden by # gitleaks:allow; "
|
||||||
|
"approve only for dummy test fixtures or confirmed false positives"
|
||||||
|
),
|
||||||
|
"arrival_timestamp": datetime.datetime.now(
|
||||||
|
datetime.timezone.utc
|
||||||
|
).isoformat(),
|
||||||
|
"current_file_hash": hashlib.sha256(payload.encode("utf-8")).hexdigest(),
|
||||||
|
}
|
||||||
|
queue = Path(queue_dir)
|
||||||
|
queue.mkdir(parents=True, exist_ok=True)
|
||||||
|
path = queue / f"{proposal_id}.proposal.json"
|
||||||
|
tmp = path.with_suffix(path.suffix + ".tmp")
|
||||||
|
with tmp.open("w", encoding="utf-8") as f:
|
||||||
|
json.dump(proposal, f, indent=2)
|
||||||
|
f.write("\n")
|
||||||
|
os.chmod(tmp, 0o600)
|
||||||
|
os.replace(tmp, path)
|
||||||
|
print(proposal_id)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
rc=$?
|
||||||
|
rm -f "$report_file"
|
||||||
|
if [ "$rc" -eq 0 ] && [ -z "$proposal_id" ]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
if [ "$rc" -ne 0 ]; then
|
||||||
|
echo "git-gate: cannot route # gitleaks:allow finding to supervisor; refusing push" >&2
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
queue_dir=${SUPERVISE_QUEUE_DIR:-}
|
||||||
|
response_file="$queue_dir/${proposal_id}.response.json"
|
||||||
|
timeout=${SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS:-300}
|
||||||
|
case "$timeout" in
|
||||||
|
''|*[!0-9]*)
|
||||||
|
echo "git-gate: invalid SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS=$timeout" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
echo "git-gate: queued # gitleaks:allow supervisor approval $proposal_id" >&2
|
||||||
|
echo "git-gate: approve with './cli.py supervise' to continue this push" >&2
|
||||||
|
waited=0
|
||||||
|
while [ "$waited" -lt "$timeout" ]; do
|
||||||
|
if [ -f "$response_file" ]; then
|
||||||
|
status=$(python3 - "$response_file" <<'PY'
|
||||||
|
import json
|
||||||
|
import sys
|
||||||
|
try:
|
||||||
|
with open(sys.argv[1], encoding="utf-8") as f:
|
||||||
|
raw = json.load(f)
|
||||||
|
except (OSError, json.JSONDecodeError):
|
||||||
|
sys.exit(1)
|
||||||
|
status = raw.get("status")
|
||||||
|
if not isinstance(status, str):
|
||||||
|
sys.exit(1)
|
||||||
|
print(status)
|
||||||
|
PY
|
||||||
|
) || status=""
|
||||||
|
case "$status" in
|
||||||
|
approved|modified)
|
||||||
|
mkdir -p "$queue_dir/processed"
|
||||||
|
mv -f "$queue_dir/${proposal_id}.proposal.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||||
|
mv -f "$queue_dir/${proposal_id}.response.json" "$queue_dir/processed/" 2>/dev/null || true
|
||||||
|
echo "git-gate: supervisor approved # gitleaks:allow for $ref" >&2
|
||||||
|
return 0
|
||||||
|
;;
|
||||||
|
rejected)
|
||||||
|
echo "git-gate: supervisor rejected # gitleaks:allow for $ref" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "git-gate: invalid supervisor response for # gitleaks:allow" >&2
|
||||||
|
return 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
fi
|
||||||
|
sleep 1
|
||||||
|
waited=$((waited + 1))
|
||||||
|
done
|
||||||
|
echo "git-gate: supervisor approval timed out for # gitleaks:allow; refusing push" >&2
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
# Phase 1: gitleaks scan each ref's incoming commits.
|
# Phase 1: gitleaks scan each ref's incoming commits.
|
||||||
while IFS=' ' read -r old new ref; do
|
while IFS=' ' read -r old new ref; do
|
||||||
[ -z "$ref" ] && continue
|
[ -z "$ref" ] && continue
|
||||||
@@ -268,6 +426,9 @@ while IFS=' ' read -r old new ref; do
|
|||||||
echo "git-gate: gitleaks rejected push to $ref" >&2
|
echo "git-gate: gitleaks rejected push to $ref" >&2
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
if ! supervise_gitleaks_allow "$log_opts" "$ref"; then
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
done < "$refs_file"
|
done < "$refs_file"
|
||||||
|
|
||||||
# Phase 2: forward each ref to the upstream (`origin`, configured
|
# Phase 2: forward each ref to the upstream (`origin`, configured
|
||||||
|
|||||||
+8
-16
@@ -19,7 +19,6 @@ Bottle schema (frontmatter):
|
|||||||
repos: { <name>: <git-gate-entry>, ... } # optional
|
repos: { <name>: <git-gate-entry>, ... } # optional
|
||||||
egress: { routes: [ <egress-route>, ... ] }
|
egress: { routes: [ <egress-route>, ... ] }
|
||||||
# route keys: host, matches, auth, role, dlp
|
# route keys: host, matches, auth, role, dlp
|
||||||
supervise: <bool> # optional
|
|
||||||
|
|
||||||
Agent schema (frontmatter):
|
Agent schema (frontmatter):
|
||||||
bottle: <bottle-name> # required
|
bottle: <bottle-name> # required
|
||||||
@@ -111,13 +110,6 @@ class ManifestBottle:
|
|||||||
# 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)
|
||||||
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
egress: ManifestEgressConfig = field(default_factory=ManifestEgressConfig)
|
||||||
# Opt-in per-bottle stuck-recovery sidecar (PRD 0013). When true,
|
|
||||||
# the launch step brings up a supervise sidecar that exposes MCP
|
|
||||||
# tools to the agent (egress-block, capability-block) plus mounts
|
|
||||||
# the current-config dir read-only into the agent at
|
|
||||||
# /etc/bot-bottle/current-config. False (the default) skips the
|
|
||||||
# sidecar and mount.
|
|
||||||
supervise: bool = False
|
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
def from_dict(cls, name: str, raw: object) -> "ManifestBottle":
|
||||||
@@ -152,6 +144,13 @@ class ManifestBottle:
|
|||||||
f"removed. Move it under 'git-gate.user'."
|
f"removed. Move it under 'git-gate.user'."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if "supervise" in d:
|
||||||
|
raise ManifestError(
|
||||||
|
f"bottle '{name}' has a 'supervise' field, which has been "
|
||||||
|
f"removed (issue #249). All bottles are now supervised; the "
|
||||||
|
f"flag was always-on in practice. Delete the field."
|
||||||
|
)
|
||||||
|
|
||||||
unknown = set(d.keys()) - BOTTLE_KEYS
|
unknown = set(d.keys()) - BOTTLE_KEYS
|
||||||
if unknown:
|
if unknown:
|
||||||
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
allowed = ", ".join(sorted(BOTTLE_KEYS))
|
||||||
@@ -190,16 +189,9 @@ class ManifestBottle:
|
|||||||
else ManifestEgressConfig()
|
else ManifestEgressConfig()
|
||||||
)
|
)
|
||||||
|
|
||||||
supervise_raw = d.get("supervise", False)
|
|
||||||
if not isinstance(supervise_raw, bool):
|
|
||||||
raise ManifestError(
|
|
||||||
f"bottle '{name}' supervise must be a boolean "
|
|
||||||
f"(was {type(supervise_raw).__name__})"
|
|
||||||
)
|
|
||||||
|
|
||||||
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, egress=egress,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -134,9 +134,6 @@ def _merge_bottles(
|
|||||||
if "agent_provider" in child_raw
|
if "agent_provider" in child_raw
|
||||||
else parent.agent_provider
|
else parent.agent_provider
|
||||||
)
|
)
|
||||||
merged_supervise = (
|
|
||||||
child.supervise if "supervise" in child_raw else parent.supervise
|
|
||||||
)
|
|
||||||
validate_egress_routes(name, merged_egress.routes)
|
validate_egress_routes(name, merged_egress.routes)
|
||||||
|
|
||||||
return ManifestBottle(
|
return ManifestBottle(
|
||||||
@@ -145,7 +142,6 @@ def _merge_bottles(
|
|||||||
git=merged_git,
|
git=merged_git,
|
||||||
git_user=merged_git_user,
|
git_user=merged_git_user,
|
||||||
egress=merged_egress,
|
egress=merged_egress,
|
||||||
supervise=merged_supervise,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ _FILENAME_RX = re.compile(r"^[a-z][a-z0-9-]*$")
|
|||||||
# sets dies with a "did you mean" pointer: typos should not silently
|
# sets dies with a "did you mean" pointer: typos should not silently
|
||||||
# ghost into an empty config.
|
# ghost into an empty config.
|
||||||
BOTTLE_KEYS = frozenset(
|
BOTTLE_KEYS = frozenset(
|
||||||
{"env", "extends", "agent_provider", "git-gate", "egress", "supervise"}
|
{"env", "extends", "agent_provider", "git-gate", "egress"}
|
||||||
)
|
)
|
||||||
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
AGENT_KEYS_REQUIRED = frozenset({"bottle"})
|
||||||
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
|
AGENT_KEYS_OPTIONAL = frozenset({"skills", "git-gate"})
|
||||||
|
|||||||
@@ -51,11 +51,13 @@ SUPERVISE_PORT = 9100
|
|||||||
TOOL_CAPABILITY_BLOCK = "capability-block"
|
TOOL_CAPABILITY_BLOCK = "capability-block"
|
||||||
TOOL_EGRESS_BLOCK = "egress-block"
|
TOOL_EGRESS_BLOCK = "egress-block"
|
||||||
TOOL_ALLOW = "allow"
|
TOOL_ALLOW = "allow"
|
||||||
|
TOOL_GITLEAKS_ALLOW = "gitleaks-allow"
|
||||||
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
TOOL_LIST_EGRESS_ROUTES = "list-egress-routes"
|
||||||
TOOLS: tuple[str, ...] = (
|
TOOLS: tuple[str, ...] = (
|
||||||
TOOL_ALLOW,
|
TOOL_ALLOW,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
TOOL_EGRESS_BLOCK,
|
TOOL_EGRESS_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
TOOL_LIST_EGRESS_ROUTES,
|
TOOL_LIST_EGRESS_ROUTES,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -553,6 +555,7 @@ __all__ = [
|
|||||||
"EGRESS_FORWARD_PROXY",
|
"EGRESS_FORWARD_PROXY",
|
||||||
"EGRESS_INTROSPECT_URL",
|
"EGRESS_INTROSPECT_URL",
|
||||||
"TOOL_CAPABILITY_BLOCK",
|
"TOOL_CAPABILITY_BLOCK",
|
||||||
|
"TOOL_GITLEAKS_ALLOW",
|
||||||
"TOOL_LIST_EGRESS_ROUTES",
|
"TOOL_LIST_EGRESS_ROUTES",
|
||||||
"archive_proposal",
|
"archive_proposal",
|
||||||
"audit_dir",
|
"audit_dir",
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# PRD prd-new: Commit bottle state to an image
|
# PRD 0060: Commit bottle state to an image
|
||||||
|
|
||||||
- **Status:** Active
|
- **Status:** Active
|
||||||
- **Author:** Claude
|
- **Author:** Claude
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
# PRD 0061: Gitleaks inline suppression supervision
|
||||||
|
|
||||||
|
- **Status:** Active
|
||||||
|
- **Author:** codex
|
||||||
|
- **Created:** 2026-06-10
|
||||||
|
- **Issue:** #208
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Route every `# gitleaks:allow` inline suppression through the existing
|
||||||
|
supervisor approval queue before git-gate forwards a push upstream. The
|
||||||
|
annotation remains available for test fixtures and confirmed false positives,
|
||||||
|
but it no longer lets an agent silently bypass the gate's secret scan.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Gitleaks supports inline suppression comments. With the default behaviour,
|
||||||
|
a pushed line that contains a secret-like value and `# gitleaks:allow` is
|
||||||
|
ignored by gitleaks and accepted by git-gate. That is convenient for dummy
|
||||||
|
credentials in tests, but it also gives an agent a one-line bypass for a real
|
||||||
|
secret. Once the commit lands, the diff alone cannot prove whether the
|
||||||
|
suppression was legitimate.
|
||||||
|
|
||||||
|
## Goals / Success Criteria
|
||||||
|
|
||||||
|
1. Git-gate continues to run the normal gitleaks scan for every incoming ref.
|
||||||
|
2. After the normal scan passes, git-gate runs a second scan with
|
||||||
|
`--ignore-gitleaks-allow` and a JSON report so suppressed findings become
|
||||||
|
visible.
|
||||||
|
3. If that second scan reports no suppressed findings, the push proceeds
|
||||||
|
unchanged.
|
||||||
|
4. If it reports suppressed findings, git-gate creates a `gitleaks-allow`
|
||||||
|
supervisor proposal containing the ref, file path, line number, rule,
|
||||||
|
commit, and flagged line for each finding.
|
||||||
|
5. The push proceeds only when the supervisor explicitly approves the
|
||||||
|
proposal; rejection, malformed responses, missing supervisor configuration,
|
||||||
|
and timeout all refuse the push.
|
||||||
|
6. The supervisor TUI requires a reason when approving a `gitleaks-allow`
|
||||||
|
proposal, so the audit trail records whether the approval was for a test
|
||||||
|
fixture or a false positive.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
|
||||||
|
- Replacing gitleaks or changing the main secret-detection rule set.
|
||||||
|
- Removing support for `# gitleaks:allow`.
|
||||||
|
- Automatically classifying fixture files or false positives.
|
||||||
|
- Adding new supervisor transport or authentication mechanisms.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Git-gate flow
|
||||||
|
|
||||||
|
`git_gate_render_hook()` emits a `supervise_gitleaks_allow` shell helper.
|
||||||
|
For each incoming ref, git-gate first runs the existing gitleaks command. If
|
||||||
|
that scan passes, it runs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
gitleaks git \
|
||||||
|
--log-opts="$log_opts" \
|
||||||
|
--no-banner \
|
||||||
|
--redact \
|
||||||
|
--ignore-gitleaks-allow \
|
||||||
|
--report-format=json \
|
||||||
|
--report-path="$report_file" \
|
||||||
|
--exit-code 0
|
||||||
|
```
|
||||||
|
|
||||||
|
The second pass keeps the push path non-interactive while producing a report
|
||||||
|
of findings that would otherwise have been hidden by inline suppression.
|
||||||
|
|
||||||
|
### Supervisor proposal
|
||||||
|
|
||||||
|
When the JSON report contains findings, an embedded Python helper writes a
|
||||||
|
proposal into `SUPERVISE_QUEUE_DIR` using the existing proposal schema. The
|
||||||
|
proposal uses:
|
||||||
|
|
||||||
|
- `tool: "gitleaks-allow"`
|
||||||
|
- a text payload with the ref and each finding's file, line, rule, commit,
|
||||||
|
and redacted code line
|
||||||
|
- a justification that tells the operator to approve only dummy test fixtures
|
||||||
|
or confirmed false positives
|
||||||
|
|
||||||
|
Git-gate then waits for `<proposal-id>.response.json` for
|
||||||
|
`SUPERVISE_GITLEAKS_ALLOW_TIMEOUT_SECONDS`, defaulting to 300 seconds.
|
||||||
|
`approved` and `modified` responses allow the push; `rejected`, invalid
|
||||||
|
responses, invalid timeout configuration, or timeout refuse it.
|
||||||
|
|
||||||
|
### Supervisor UI
|
||||||
|
|
||||||
|
`TOOL_GITLEAKS_ALLOW` is added to the supervisor tool registry. The curses
|
||||||
|
supervisor renders the proposal as text and allows approval or rejection.
|
||||||
|
Modification is unavailable for this proposal type because there is no file
|
||||||
|
patch to apply. Approval from the TUI prompts for a non-empty reason and
|
||||||
|
writes that reason to the response/audit path.
|
||||||
|
|
||||||
|
### Tests
|
||||||
|
|
||||||
|
Unit tests assert that the rendered git-gate hook includes the second gitleaks
|
||||||
|
pass, supervisor queue fields, and fail-closed messages. Supervisor tests cover
|
||||||
|
the new tool constant, proposal archiving, and the required TUI approval
|
||||||
|
reason.
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
---
|
---
|
||||||
agent_provider:
|
agent_provider:
|
||||||
template: claude
|
template: claude
|
||||||
|
# auth_token names the host env var holding the Claude OAuth token. The
|
||||||
egress:
|
# provider injects a provider-owned api.anthropic.com egress route that
|
||||||
routes:
|
# re-injects this token as the Bearer header; the agent only ever sees a
|
||||||
- host: api.anthropic.com
|
# placeholder CLAUDE_CODE_OAUTH_TOKEN. DLP defaults (token_patterns,
|
||||||
role: claude_code_oauth
|
# known_secrets outbound; naive_injection_detection inbound) apply to
|
||||||
auth:
|
# that route. To scan additional hosts, declare them under egress.routes
|
||||||
scheme: Bearer
|
# with per-route matches/dlp (see README "Egress route fields").
|
||||||
token_ref: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
auth_token: BOT_BOTTLE_CLAUDE_OAUTH_TOKEN
|
||||||
---
|
---
|
||||||
|
|
||||||
Common Claude provider boundary. Drop this file into
|
Common Claude provider boundary. Drop this file into
|
||||||
|
|||||||
@@ -27,14 +27,13 @@ from tests._docker import skip_unless_docker
|
|||||||
|
|
||||||
|
|
||||||
def _manifest() -> ManifestIndex:
|
def _manifest() -> ManifestIndex:
|
||||||
"""Bottle with supervise on so the bundle exercises egress +
|
"""Minimal bottle so the bundle exercises egress + supervise
|
||||||
supervise. Git is off because a meaningful git-gate test needs
|
(every bottle is supervised, issue #249). Git is off because a
|
||||||
a real upstream and SSH keys — out of scope for a bundle smoke."""
|
meaningful git-gate test needs a real upstream and SSH keys —
|
||||||
|
out of scope for a bundle smoke."""
|
||||||
return ManifestIndex.from_json_obj({
|
return ManifestIndex.from_json_obj({
|
||||||
"bottles": {
|
"bottles": {
|
||||||
"dev": {
|
"dev": {},
|
||||||
"supervise": True,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
"agents": {
|
"agents": {
|
||||||
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
"demo": {"skills": [], "prompt": "", "bottle": "dev"},
|
||||||
|
|||||||
+27
-33
@@ -40,13 +40,11 @@ STAGE = Path("/tmp/cb-stage")
|
|||||||
STATE = Path("/tmp/cb-state")
|
STATE = Path("/tmp/cb-state")
|
||||||
|
|
||||||
|
|
||||||
def _manifest(*, supervise: bool, with_git: bool, with_egress: bool) -> ManifestIndex:
|
def _manifest(*, with_git: bool, with_egress: bool) -> ManifestIndex:
|
||||||
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
"""Minimal manifest with the toggles the chunk-1 matrix needs.
|
||||||
The renderer only reads from the plan, not the manifest, so this
|
The renderer only reads from the plan, not the manifest, so this
|
||||||
is just here to back BottleSpec."""
|
is just here to back BottleSpec."""
|
||||||
bottle: dict[str, object] = {}
|
bottle: dict[str, object] = {}
|
||||||
if supervise:
|
|
||||||
bottle["supervise"] = True
|
|
||||||
if with_git:
|
if with_git:
|
||||||
bottle["git-gate"] = {"repos": {
|
bottle["git-gate"] = {"repos": {
|
||||||
"upstream": {
|
"upstream": {
|
||||||
@@ -111,10 +109,11 @@ def _plan(
|
|||||||
*,
|
*,
|
||||||
with_git: bool = False,
|
with_git: bool = False,
|
||||||
with_egress: bool = False,
|
with_egress: bool = False,
|
||||||
supervise: bool = False,
|
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
"""Build a fully-resolved DockerBottlePlan. Toggles cover the
|
||||||
matrix the renderer's conditional-service logic branches on."""
|
matrix the renderer's conditional-service logic branches on.
|
||||||
|
Every bottle is supervised (issue #249), so the supervise plan
|
||||||
|
is always present."""
|
||||||
upstreams: tuple[GitGateUpstream, ...] = ()
|
upstreams: tuple[GitGateUpstream, ...] = ()
|
||||||
if with_git:
|
if with_git:
|
||||||
upstreams = (GitGateUpstream(
|
upstreams = (GitGateUpstream(
|
||||||
@@ -136,7 +135,7 @@ def _plan(
|
|||||||
roles=(),
|
roles=(),
|
||||||
),)
|
),)
|
||||||
|
|
||||||
index = _manifest(supervise=supervise, with_git=with_git, with_egress=with_egress)
|
index = _manifest(with_git=with_git, with_egress=with_egress)
|
||||||
spec = BottleSpec(
|
spec = BottleSpec(
|
||||||
manifest=index,
|
manifest=index,
|
||||||
agent_name="demo",
|
agent_name="demo",
|
||||||
@@ -151,7 +150,7 @@ def _plan(
|
|||||||
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
forwarded_env={"CLAUDE_CODE_OAUTH_TOKEN": "x"},
|
||||||
git_gate_plan=_git_gate_plan(upstreams),
|
git_gate_plan=_git_gate_plan(upstreams),
|
||||||
egress_plan=_egress_plan(routes),
|
egress_plan=_egress_plan(routes),
|
||||||
supervise_plan=_supervise_plan() if supervise else None,
|
supervise_plan=_supervise_plan(),
|
||||||
use_runsc=False,
|
use_runsc=False,
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
@@ -220,10 +219,8 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
|
proxy = [e for e in s["environment"] if e.startswith("HTTPS_PROXY=")][0]
|
||||||
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
|
self.assertEqual("HTTPS_PROXY=http://egress:9099", proxy)
|
||||||
|
|
||||||
def test_agent_no_proxy_adds_supervise_when_enabled(self):
|
def test_agent_no_proxy_includes_supervise(self):
|
||||||
s = bottle_plan_to_compose(
|
s = bottle_plan_to_compose(_plan())["services"]["agent"]
|
||||||
_plan(supervise=True)
|
|
||||||
)["services"]["agent"]
|
|
||||||
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
|
no_proxy = [e for e in s["environment"] if e.startswith("NO_PROXY=")][0]
|
||||||
self.assertIn("supervise", no_proxy)
|
self.assertIn("supervise", no_proxy)
|
||||||
|
|
||||||
@@ -259,22 +256,18 @@ class TestAgentAlwaysPresent(unittest.TestCase):
|
|||||||
def test_agent_depends_only_on_sidecars(self):
|
def test_agent_depends_only_on_sidecars(self):
|
||||||
# Bundle shape: the init supervisor owns intra-bundle daemon
|
# Bundle shape: the init supervisor owns intra-bundle daemon
|
||||||
# ordering, so the agent waits on the bundle container alone.
|
# ordering, so the agent waits on the bundle container alone.
|
||||||
for kwargs in [{}, {"with_git": True, "with_egress": True, "supervise": True}]:
|
for kwargs in [{}, {"with_git": True, "with_egress": True}]:
|
||||||
with self.subTest(**kwargs):
|
with self.subTest(**kwargs):
|
||||||
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
s = bottle_plan_to_compose(_plan(**kwargs))["services"]["agent"]
|
||||||
self.assertEqual(["sidecars"], s["depends_on"])
|
self.assertEqual(["sidecars"], s["depends_on"])
|
||||||
|
|
||||||
def test_agent_current_config_mount_only_with_supervise(self):
|
def test_agent_current_config_always_mounted(self):
|
||||||
with_sv = bottle_plan_to_compose(_plan(supervise=True))["services"]["agent"]
|
# Every bottle is supervised (issue #249), so the read-only
|
||||||
|
# current-config mount is always present in the agent.
|
||||||
|
agent = bottle_plan_to_compose(_plan())["services"]["agent"]
|
||||||
self.assertTrue(any(
|
self.assertTrue(any(
|
||||||
v["target"] == "/etc/bot-bottle/current-config"
|
v["target"] == "/etc/bot-bottle/current-config"
|
||||||
for v in with_sv.get("volumes", [])
|
for v in agent.get("volumes", [])
|
||||||
))
|
|
||||||
without_sv = bottle_plan_to_compose(_plan(supervise=False))["services"]["agent"]
|
|
||||||
# Either no volumes key at all, or no current-config target.
|
|
||||||
self.assertFalse(any(
|
|
||||||
v["target"] == "/etc/bot-bottle/current-config"
|
|
||||||
for v in without_sv.get("volumes", [])
|
|
||||||
))
|
))
|
||||||
|
|
||||||
|
|
||||||
@@ -292,7 +285,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
||||||
|
|
||||||
def test_emits_two_services_full_matrix(self):
|
def test_emits_two_services_full_matrix(self):
|
||||||
spec = self._render(with_git=True, with_egress=True, supervise=True)
|
spec = self._render(with_git=True, with_egress=True)
|
||||||
# Still two services — the bundle absorbs git-gate/egress/supervise.
|
# Still two services — the bundle absorbs git-gate/egress/supervise.
|
||||||
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
self.assertEqual({"sidecars", "agent"}, set(spec["services"].keys()))
|
||||||
|
|
||||||
@@ -315,16 +308,16 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertIn("egress", aliases)
|
self.assertIn("egress", aliases)
|
||||||
|
|
||||||
def test_internal_aliases_omit_inactive_sidecars(self):
|
def test_internal_aliases_omit_inactive_sidecars(self):
|
||||||
# With no git-gate / supervise, those names are NOT aliased
|
# With no git-gate, that name is NOT aliased — keeps the alias
|
||||||
# — keeps the alias list honest about what's actually
|
# list honest about what's actually listening inside the bundle.
|
||||||
# listening inside the bundle.
|
# supervise is always present (issue #249).
|
||||||
sc = self._render()["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||||
self.assertNotIn("git-gate", aliases)
|
self.assertNotIn("git-gate", aliases)
|
||||||
self.assertNotIn("supervise", aliases)
|
self.assertIn("supervise", aliases)
|
||||||
|
|
||||||
def test_internal_aliases_include_active_sidecars(self):
|
def test_internal_aliases_include_active_sidecars(self):
|
||||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||||
aliases = set(sc["networks"]["internal"]["aliases"])
|
aliases = set(sc["networks"]["internal"]["aliases"])
|
||||||
self.assertIn("git-gate", aliases)
|
self.assertIn("git-gate", aliases)
|
||||||
self.assertIn("supervise", aliases)
|
self.assertIn("supervise", aliases)
|
||||||
@@ -336,10 +329,11 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
for line in sc["environment"]
|
for line in sc["environment"]
|
||||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
|
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS=")
|
||||||
}
|
}
|
||||||
self.assertEqual({"egress"}, daemons)
|
# egress + supervise are always present (issue #249).
|
||||||
|
self.assertEqual({"egress,supervise"}, daemons)
|
||||||
|
|
||||||
def test_daemons_csv_expands_with_optional_sidecars(self):
|
def test_daemons_csv_expands_with_optional_sidecars(self):
|
||||||
sc = self._render(with_git=True, supervise=True)["services"]["sidecars"]
|
sc = self._render(with_git=True)["services"]["sidecars"]
|
||||||
for line in sc["environment"]:
|
for line in sc["environment"]:
|
||||||
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
|
if line.startswith("BOT_BOTTLE_SIDECAR_DAEMONS="):
|
||||||
csv = line.split("=", 1)[1]
|
csv = line.split("=", 1)[1]
|
||||||
@@ -347,7 +341,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
else:
|
else:
|
||||||
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
|
self.fail("BOT_BOTTLE_SIDECAR_DAEMONS not in env")
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
["egress", "git-gate", "supervise"],
|
["egress", "supervise", "git-gate"],
|
||||||
csv.split(","),
|
csv.split(","),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -376,7 +370,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
|
self.assertNotIn("EGRESS_TOKEN_0", env_strings)
|
||||||
|
|
||||||
def test_supervise_env_present_when_active(self):
|
def test_supervise_env_present_when_active(self):
|
||||||
sc = self._render(supervise=True)["services"]["sidecars"]
|
sc = self._render()["services"]["sidecars"]
|
||||||
env_strings = sc["environment"]
|
env_strings = sc["environment"]
|
||||||
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
self.assertIn(f"SUPERVISE_BOTTLE_SLUG={SLUG}", env_strings)
|
||||||
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
|
self.assertTrue(any(e.startswith("SUPERVISE_QUEUE_DIR=") for e in env_strings))
|
||||||
@@ -388,7 +382,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||||
|
|
||||||
def test_volumes_union_full_matrix(self):
|
def test_volumes_union_full_matrix(self):
|
||||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
sc = self._render(with_git=True, with_egress=True)[
|
||||||
"services"]["sidecars"]
|
"services"]["sidecars"]
|
||||||
targets = {v["target"] for v in sc["volumes"]}
|
targets = {v["target"] for v in sc["volumes"]}
|
||||||
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
self.assertIn("/home/mitmproxy/.mitmproxy/mitmproxy-ca.pem", targets)
|
||||||
@@ -403,7 +397,7 @@ class TestSidecarBundleShape(unittest.TestCase):
|
|||||||
self.assertNotIn("extra_hosts", sc)
|
self.assertNotIn("extra_hosts", sc)
|
||||||
|
|
||||||
def test_agent_depends_on_bundle_only(self):
|
def test_agent_depends_on_bundle_only(self):
|
||||||
sc = self._render(with_git=True, with_egress=True, supervise=True)[
|
sc = self._render(with_git=True, with_egress=True)[
|
||||||
"services"]["agent"]
|
"services"]["agent"]
|
||||||
self.assertEqual(["sidecars"], sc["depends_on"])
|
self.assertEqual(["sidecars"], sc["depends_on"])
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,8 @@ def _plan(
|
|||||||
agent_prompt: str = "",
|
agent_prompt: str = "",
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
agent_provision: AgentProvisionPlan | None = None,
|
agent_provision: AgentProvisionPlan | None = None,
|
||||||
supervise: bool = False,
|
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
|
bottle_json: dict = {"agent_provider": {"template": "claude"}} # type: ignore
|
||||||
if supervise:
|
|
||||||
bottle_json["supervise"] = True
|
|
||||||
index = ManifestIndex.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -70,13 +67,11 @@ def _plan(
|
|||||||
manifest=index, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=False, user_cwd="/tmp/x",
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
)
|
)
|
||||||
supervise_plan = None
|
supervise_plan = SupervisePlan(
|
||||||
if supervise:
|
slug="demo-abc12",
|
||||||
supervise_plan = SupervisePlan(
|
queue_dir=Path("/tmp/queue"),
|
||||||
slug="demo-abc12",
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
queue_dir=Path("/tmp/queue"),
|
)
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -314,17 +309,10 @@ class TestClaudeUiProvision(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestClaudeSuperviseMcp(unittest.TestCase):
|
class TestClaudeSuperviseMcp(unittest.TestCase):
|
||||||
def test_noop_when_supervise_disabled(self):
|
|
||||||
bottle = _make_bottle()
|
|
||||||
ClaudeAgentProvider().provision_supervise_mcp(
|
|
||||||
_plan(supervise=False), bottle, _URL,
|
|
||||||
)
|
|
||||||
bottle.exec.assert_not_called()
|
|
||||||
|
|
||||||
def test_runs_claude_mcp_add_as_node(self):
|
def test_runs_claude_mcp_add_as_node(self):
|
||||||
bottle = _make_bottle()
|
bottle = _make_bottle()
|
||||||
ClaudeAgentProvider().provision_supervise_mcp(
|
ClaudeAgentProvider().provision_supervise_mcp(
|
||||||
_plan(supervise=True), bottle, _URL,
|
_plan(), bottle, _URL,
|
||||||
)
|
)
|
||||||
bottle.exec.assert_called_once()
|
bottle.exec.assert_called_once()
|
||||||
script = bottle.exec.call_args.args[0]
|
script = bottle.exec.call_args.args[0]
|
||||||
@@ -340,7 +328,7 @@ class TestClaudeSuperviseMcp(unittest.TestCase):
|
|||||||
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
||||||
)
|
)
|
||||||
ClaudeAgentProvider().provision_supervise_mcp(
|
ClaudeAgentProvider().provision_supervise_mcp(
|
||||||
_plan(supervise=True), bottle, _URL,
|
_plan(), bottle, _URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -50,11 +50,8 @@ def _plan(
|
|||||||
agent_prompt: str = "",
|
agent_prompt: str = "",
|
||||||
skills: list[str] | None = None,
|
skills: list[str] | None = None,
|
||||||
agent_provision: AgentProvisionPlan | None = None,
|
agent_provision: AgentProvisionPlan | None = None,
|
||||||
supervise: bool = False,
|
|
||||||
) -> DockerBottlePlan:
|
) -> DockerBottlePlan:
|
||||||
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
|
bottle_json: dict = {"agent_provider": {"template": "codex"}} # type: ignore
|
||||||
if supervise:
|
|
||||||
bottle_json["supervise"] = True
|
|
||||||
index = ManifestIndex.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -70,13 +67,11 @@ def _plan(
|
|||||||
manifest=index, agent_name="demo",
|
manifest=index, agent_name="demo",
|
||||||
copy_cwd=False, user_cwd="/tmp/x",
|
copy_cwd=False, user_cwd="/tmp/x",
|
||||||
)
|
)
|
||||||
supervise_plan = None
|
supervise_plan = SupervisePlan(
|
||||||
if supervise:
|
slug="demo-abc12",
|
||||||
supervise_plan = SupervisePlan(
|
queue_dir=Path("/tmp/queue"),
|
||||||
slug="demo-abc12",
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
queue_dir=Path("/tmp/queue"),
|
)
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
|
||||||
return DockerBottlePlan(
|
return DockerBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -277,17 +272,10 @@ class TestCodexProvision(unittest.TestCase):
|
|||||||
|
|
||||||
|
|
||||||
class TestCodexSuperviseMcp(unittest.TestCase):
|
class TestCodexSuperviseMcp(unittest.TestCase):
|
||||||
def test_noop_when_supervise_disabled(self):
|
|
||||||
bottle = _make_bottle()
|
|
||||||
CodexAgentProvider().provision_supervise_mcp(
|
|
||||||
_plan(supervise=False), bottle, _URL,
|
|
||||||
)
|
|
||||||
bottle.exec.assert_not_called()
|
|
||||||
|
|
||||||
def test_runs_codex_mcp_add_as_node(self):
|
def test_runs_codex_mcp_add_as_node(self):
|
||||||
bottle = _make_bottle()
|
bottle = _make_bottle()
|
||||||
CodexAgentProvider().provision_supervise_mcp(
|
CodexAgentProvider().provision_supervise_mcp(
|
||||||
_plan(supervise=True), bottle, _URL,
|
_plan(), bottle, _URL,
|
||||||
)
|
)
|
||||||
bottle.exec.assert_called_once()
|
bottle.exec.assert_called_once()
|
||||||
script = bottle.exec.call_args.args[0]
|
script = bottle.exec.call_args.args[0]
|
||||||
@@ -302,7 +290,7 @@ class TestCodexSuperviseMcp(unittest.TestCase):
|
|||||||
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
exec_result=ExecResult(returncode=1, stdout="", stderr="boom"),
|
||||||
)
|
)
|
||||||
CodexAgentProvider().provision_supervise_mcp(
|
CodexAgentProvider().provision_supervise_mcp(
|
||||||
_plan(supervise=True), bottle, _URL,
|
_plan(), bottle, _URL,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
|
from bot_bottle.contrib.pi.agent_provider import PiAgentProvider
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
@@ -77,7 +78,11 @@ def _plan(
|
|||||||
routes=(),
|
routes=(),
|
||||||
token_env_map={},
|
token_env_map={},
|
||||||
),
|
),
|
||||||
supervise_plan=None,
|
supervise_plan=SupervisePlan(
|
||||||
|
slug="demo-abc12",
|
||||||
|
queue_dir=Path("/tmp/queue"),
|
||||||
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
|
),
|
||||||
use_runsc=False,
|
use_runsc=False,
|
||||||
agent_provision=agent_provision or AgentProvisionPlan(
|
agent_provision=agent_provision or AgentProvisionPlan(
|
||||||
template="pi", command="pi", prompt_mode="append_system_prompt",
|
template="pi", command="pi", prompt_mode="append_system_prompt",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from bot_bottle.backend.docker import launch as launch_mod
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
@@ -55,7 +56,11 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
routes=(),
|
routes=(),
|
||||||
token_env_map={},
|
token_env_map={},
|
||||||
),
|
),
|
||||||
supervise_plan=None,
|
supervise_plan=SupervisePlan(
|
||||||
|
slug=_SLUG,
|
||||||
|
queue_dir=stage / "supervise" / "queue",
|
||||||
|
current_config_dir=stage / "supervise" / "current-config",
|
||||||
|
),
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
command="claude",
|
command="claude",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from bot_bottle.backend.docker import launch as launch_mod
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
_INDEX = ManifestIndex.from_json_obj({
|
_INDEX = ManifestIndex.from_json_obj({
|
||||||
@@ -56,7 +57,11 @@ def _plan(tmp: str) -> DockerBottlePlan:
|
|||||||
routes=(),
|
routes=(),
|
||||||
token_env_map={},
|
token_env_map={},
|
||||||
),
|
),
|
||||||
supervise_plan=None,
|
supervise_plan=SupervisePlan(
|
||||||
|
slug="test-teardown-00001",
|
||||||
|
queue_dir=stage / "supervise" / "queue",
|
||||||
|
current_config_dir=stage / "supervise" / "current-config",
|
||||||
|
),
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
command="claude",
|
command="claude",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ from bot_bottle.backend import Bottle, BottleSpec, ExecResult
|
|||||||
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
@@ -79,7 +80,11 @@ def _plan(*, git_user: dict | None = None, # type: ignore
|
|||||||
routes=(),
|
routes=(),
|
||||||
token_env_map={},
|
token_env_map={},
|
||||||
),
|
),
|
||||||
supervise_plan=None,
|
supervise_plan=SupervisePlan(
|
||||||
|
slug="demo-abc12",
|
||||||
|
queue_dir=Path("/tmp/queue"),
|
||||||
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
|
),
|
||||||
use_runsc=False,
|
use_runsc=False,
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
|
|||||||
@@ -199,6 +199,30 @@ class TestHookRender(unittest.TestCase):
|
|||||||
self.assertIn('set -- "$@" --push-option="$opt"', hook)
|
self.assertIn('set -- "$@" --push-option="$opt"', hook)
|
||||||
self.assertIn('git push "$@" origin "$refspec"', hook)
|
self.assertIn('git push "$@" origin "$refspec"', hook)
|
||||||
|
|
||||||
|
def test_inline_gitleaks_allow_routes_to_supervisor(self):
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
# First gitleaks runs normally; only if that passes does the
|
||||||
|
# hook ask gitleaks to ignore inline allow comments and report
|
||||||
|
# the suppressed findings for human approval.
|
||||||
|
self.assertIn("--ignore-gitleaks-allow", hook)
|
||||||
|
self.assertIn("--report-format=json", hook)
|
||||||
|
self.assertIn('"tool": "gitleaks-allow"', hook)
|
||||||
|
self.assertIn("SUPERVISE_QUEUE_DIR", hook)
|
||||||
|
self.assertIn("SUPERVISE_BOTTLE_SLUG", hook)
|
||||||
|
self.assertIn("supervisor approved # gitleaks:allow", hook)
|
||||||
|
self.assertIn("supervisor rejected # gitleaks:allow", hook)
|
||||||
|
|
||||||
|
def test_inline_gitleaks_allow_fails_closed_without_supervisor(self):
|
||||||
|
hook = git_gate_render_hook()
|
||||||
|
self.assertIn(
|
||||||
|
"cannot route # gitleaks:allow finding to supervisor; refusing push",
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
self.assertIn(
|
||||||
|
"supervisor approval timed out for # gitleaks:allow; refusing push",
|
||||||
|
hook,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class TestAccessHookRender(unittest.TestCase):
|
class TestAccessHookRender(unittest.TestCase):
|
||||||
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
def test_access_hook_refreshes_origin_on_upload_pack(self):
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ from bot_bottle.backend.macos_container import launch
|
|||||||
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
from bot_bottle.backend.macos_container.bottle_plan import MacosContainerBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan
|
from bot_bottle.egress import EgressPlan
|
||||||
from bot_bottle.git_gate import GitGatePlan
|
from bot_bottle.git_gate import GitGatePlan
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import ManifestIndex
|
from bot_bottle.manifest import ManifestIndex
|
||||||
|
|
||||||
_MANIFEST = ManifestIndex.from_json_obj({
|
_MANIFEST = ManifestIndex.from_json_obj({
|
||||||
@@ -27,7 +28,6 @@ def _plan(
|
|||||||
*,
|
*,
|
||||||
stage_dir: Path,
|
stage_dir: Path,
|
||||||
git: bool = False,
|
git: bool = False,
|
||||||
supervise: bool = False,
|
|
||||||
agent_git_gate_url: str = "",
|
agent_git_gate_url: str = "",
|
||||||
agent_supervise_url: str = "",
|
agent_supervise_url: str = "",
|
||||||
) -> MacosContainerBottlePlan:
|
) -> MacosContainerBottlePlan:
|
||||||
@@ -67,10 +67,8 @@ def _plan(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
git_gate_plan = SimpleNamespace(upstreams=())
|
git_gate_plan = SimpleNamespace(upstreams=())
|
||||||
supervise_plan = (
|
# Every bottle is supervised (issue #249).
|
||||||
SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
supervise_plan = SimpleNamespace(queue_dir=Path("/state/supervise/queue"))
|
||||||
if supervise else None
|
|
||||||
)
|
|
||||||
agent_provision = SimpleNamespace(
|
agent_provision = SimpleNamespace(
|
||||||
guest_env={"LITERAL": "value"},
|
guest_env={"LITERAL": "value"},
|
||||||
provisioned_env={"CODEX_HOME": "/run/codex-home"},
|
provisioned_env={"CODEX_HOME": "/run/codex-home"},
|
||||||
@@ -101,7 +99,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
self._tmp.cleanup()
|
self._tmp.cleanup()
|
||||||
|
|
||||||
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
|
def test_sidecar_argv_uses_egress_network_first_and_explicit_dns(self):
|
||||||
plan = _plan(stage_dir=self.stage_dir, supervise=True)
|
plan = _plan(stage_dir=self.stage_dir)
|
||||||
with patch.object(launch.os, "environ", {
|
with patch.object(launch.os, "environ", {
|
||||||
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
"BOT_BOTTLE_MACOS_CONTAINER_DNS": "9.9.9.9",
|
||||||
}):
|
}):
|
||||||
@@ -172,7 +170,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
def test_git_gate_daemons_are_ready_gated(self):
|
def test_git_gate_daemons_are_ready_gated(self):
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True)
|
plan = _plan(stage_dir=self.stage_dir, git=True)
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
("egress", "git-gate", "git-http"),
|
("egress", "supervise", "git-gate", "git-http"),
|
||||||
launch._sidecar_daemons(plan),
|
launch._sidecar_daemons(plan),
|
||||||
)
|
)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
@@ -181,7 +179,7 @@ class TestMacosContainerLaunchArgv(unittest.TestCase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
|
def test_stamp_agent_urls_includes_git_http_when_git_gate_exists(self):
|
||||||
plan = _plan(stage_dir=self.stage_dir, git=True, supervise=True)
|
plan = _plan(stage_dir=self.stage_dir, git=True)
|
||||||
with patch.object(launch.dataclasses, "replace") as replace:
|
with patch.object(launch.dataclasses, "replace") as replace:
|
||||||
launch._stamp_agent_urls(plan, "192.168.128.2")
|
launch._stamp_agent_urls(plan, "192.168.128.2")
|
||||||
replace.assert_called_once_with(
|
replace.assert_called_once_with(
|
||||||
@@ -272,7 +270,10 @@ def _build_plan(stage_dir: Path) -> MacosContainerBottlePlan:
|
|||||||
stage_dir=stage_dir,
|
stage_dir=stage_dir,
|
||||||
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
|
git_gate_plan=cast(GitGatePlan, SimpleNamespace(upstreams=())),
|
||||||
egress_plan=cast(EgressPlan, SimpleNamespace()),
|
egress_plan=cast(EgressPlan, SimpleNamespace()),
|
||||||
supervise_plan=None,
|
supervise_plan=cast(
|
||||||
|
SupervisePlan,
|
||||||
|
SimpleNamespace(queue_dir=Path("/state/supervise/queue")),
|
||||||
|
),
|
||||||
agent_provision=AgentProvisionPlan(
|
agent_provision=AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
command="claude",
|
command="claude",
|
||||||
|
|||||||
@@ -116,7 +116,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
idx = ManifestIndex.from_json_obj({
|
idx = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": {
|
"bottles": {"dev": {
|
||||||
"env": {"FOO": "bar"},
|
"env": {"FOO": "bar"},
|
||||||
"supervise": True,
|
|
||||||
"git-gate": {"user": {"name": "B"}},
|
"git-gate": {"user": {"name": "B"}},
|
||||||
}},
|
}},
|
||||||
"agents": {"impl": {
|
"agents": {"impl": {
|
||||||
@@ -127,7 +126,6 @@ class TestAgentGitUserOverlay(unittest.TestCase):
|
|||||||
b = idx.load_for_agent("impl").bottle
|
b = idx.load_for_agent("impl").bottle
|
||||||
self.assertEqual("a", b.git_user.name)
|
self.assertEqual("a", b.git_user.name)
|
||||||
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
||||||
self.assertTrue(b.supervise)
|
|
||||||
|
|
||||||
|
|
||||||
class TestAgentGitUserRejections(unittest.TestCase):
|
class TestAgentGitUserRejections(unittest.TestCase):
|
||||||
|
|||||||
@@ -42,38 +42,26 @@ class TestExtendsBasic(unittest.TestCase):
|
|||||||
# same way they did before the resolver landed.
|
# same way they did before the resolver landed.
|
||||||
m = _build(dev={
|
m = _build(dev={
|
||||||
"env": {"FOO": "bar"},
|
"env": {"FOO": "bar"},
|
||||||
"supervise": True,
|
|
||||||
})
|
})
|
||||||
b = m.bottles["dev"]
|
b = m.bottles["dev"]
|
||||||
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
self.assertEqual({"FOO": "bar"}, dict(b.env))
|
||||||
self.assertTrue(b.supervise)
|
|
||||||
|
|
||||||
def test_child_inherits_parent_fields_unchanged(self):
|
def test_child_inherits_parent_fields_unchanged(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
base={
|
base={
|
||||||
"env": {"BASE": "1"},
|
"env": {"BASE": "1"},
|
||||||
"supervise": True,
|
|
||||||
},
|
},
|
||||||
child={"extends": "base"},
|
child={"extends": "base"},
|
||||||
)
|
)
|
||||||
c = m.bottles["child"]
|
c = m.bottles["child"]
|
||||||
self.assertEqual({"BASE": "1"}, dict(c.env))
|
self.assertEqual({"BASE": "1"}, dict(c.env))
|
||||||
self.assertTrue(c.supervise)
|
|
||||||
|
|
||||||
def test_child_overrides_supervise_scalar(self):
|
|
||||||
m = _build(
|
|
||||||
base={"supervise": True},
|
|
||||||
off={"extends": "base", "supervise": False},
|
|
||||||
)
|
|
||||||
self.assertTrue(m.bottles["base"].supervise)
|
|
||||||
self.assertFalse(m.bottles["off"].supervise)
|
|
||||||
|
|
||||||
def test_parent_resolved_once_for_multiple_children(self):
|
def test_parent_resolved_once_for_multiple_children(self):
|
||||||
# Two children sharing one parent: both inherit; the parent
|
# Two children sharing one parent: both inherit; the parent
|
||||||
# is resolved once + cached. (Cache behavior is internal; we
|
# is resolved once + cached. (Cache behavior is internal; we
|
||||||
# observe correctness on both children.)
|
# observe correctness on both children.)
|
||||||
m = _build(
|
m = _build(
|
||||||
base={"env": {"BASE": "1"}, "supervise": True},
|
base={"env": {"BASE": "1"}},
|
||||||
a={"extends": "base", "env": {"A": "1"}},
|
a={"extends": "base", "env": {"A": "1"}},
|
||||||
b={"extends": "base", "env": {"B": "1"}},
|
b={"extends": "base", "env": {"B": "1"}},
|
||||||
)
|
)
|
||||||
@@ -366,7 +354,6 @@ class TestExtendsChain(unittest.TestCase):
|
|||||||
m = _build(
|
m = _build(
|
||||||
grandparent={
|
grandparent={
|
||||||
"env": {"GP": "1"},
|
"env": {"GP": "1"},
|
||||||
"supervise": True,
|
|
||||||
},
|
},
|
||||||
parent={
|
parent={
|
||||||
"extends": "grandparent",
|
"extends": "grandparent",
|
||||||
@@ -381,8 +368,6 @@ class TestExtendsChain(unittest.TestCase):
|
|||||||
{"GP": "1", "P": "1", "C": "1"},
|
{"GP": "1", "P": "1", "C": "1"},
|
||||||
dict(m.bottles["child"].env),
|
dict(m.bottles["child"].env),
|
||||||
)
|
)
|
||||||
# supervise threads through unchanged.
|
|
||||||
self.assertTrue(m.bottles["child"].supervise)
|
|
||||||
|
|
||||||
def test_intermediate_can_override(self):
|
def test_intermediate_can_override(self):
|
||||||
m = _build(
|
m = _build(
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from bot_bottle.backend.docker.bottle_plan import DockerBottlePlan
|
|||||||
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
from bot_bottle.backend.smolmachines.bottle_plan import SmolmachinesBottlePlan
|
||||||
from bot_bottle.egress import EgressPlan, EgressRoute
|
from bot_bottle.egress import EgressPlan, EgressRoute
|
||||||
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
from bot_bottle.git_gate import GitGatePlan, GitGateUpstream
|
||||||
|
from bot_bottle.supervise import SupervisePlan
|
||||||
from bot_bottle.manifest import Manifest, ManifestIndex
|
from bot_bottle.manifest import Manifest, ManifestIndex
|
||||||
|
|
||||||
|
|
||||||
@@ -77,6 +78,15 @@ def _egress_plan(tmp: str) -> EgressPlan:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _supervise_plan(tmp: str) -> SupervisePlan:
|
||||||
|
stage = Path(tmp)
|
||||||
|
return SupervisePlan(
|
||||||
|
slug="test-00001",
|
||||||
|
queue_dir=stage / "supervise" / "queue",
|
||||||
|
current_config_dir=stage / "supervise" / "current-config",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _agent_provision(tmp: str) -> AgentProvisionPlan:
|
def _agent_provision(tmp: str) -> AgentProvisionPlan:
|
||||||
return AgentProvisionPlan(
|
return AgentProvisionPlan(
|
||||||
template="claude",
|
template="claude",
|
||||||
@@ -99,7 +109,7 @@ def _docker_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> DockerBottle
|
|||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
egress_plan=_egress_plan(tmp),
|
egress_plan=_egress_plan(tmp),
|
||||||
supervise_plan=None,
|
supervise_plan=_supervise_plan(tmp),
|
||||||
agent_provision=_agent_provision(tmp),
|
agent_provision=_agent_provision(tmp),
|
||||||
slug="test-00001",
|
slug="test-00001",
|
||||||
forwarded_env={},
|
forwarded_env={},
|
||||||
@@ -115,7 +125,7 @@ def _smolmachines_plan(spec: BottleSpec, manifest: Manifest, tmp: str) -> Smolma
|
|||||||
stage_dir=stage,
|
stage_dir=stage,
|
||||||
git_gate_plan=_git_gate_plan(tmp),
|
git_gate_plan=_git_gate_plan(tmp),
|
||||||
egress_plan=_egress_plan(tmp),
|
egress_plan=_egress_plan(tmp),
|
||||||
supervise_plan=None,
|
supervise_plan=_supervise_plan(tmp),
|
||||||
agent_provision=_agent_provision(tmp),
|
agent_provision=_agent_provision(tmp),
|
||||||
slug="test-00001",
|
slug="test-00001",
|
||||||
bundle_subnet="10.99.0.0/24",
|
bundle_subnet="10.99.0.0/24",
|
||||||
|
|||||||
@@ -86,7 +86,6 @@ def _plan(
|
|||||||
stage_dir: Path | None = None,
|
stage_dir: Path | None = None,
|
||||||
egress_routes: tuple[EgressRoute, ...] = (),
|
egress_routes: tuple[EgressRoute, ...] = (),
|
||||||
egress_ca_path: Path = Path(),
|
egress_ca_path: Path = Path(),
|
||||||
supervise: bool = False,
|
|
||||||
bundle_ip: str = "192.168.50.2",
|
bundle_ip: str = "192.168.50.2",
|
||||||
agent_git_gate_host: str = "127.0.0.1:55555",
|
agent_git_gate_host: str = "127.0.0.1:55555",
|
||||||
agent_supervise_url: str = "http://127.0.0.1:55556/",
|
agent_supervise_url: str = "http://127.0.0.1:55556/",
|
||||||
@@ -108,8 +107,6 @@ def _plan(
|
|||||||
git_gate_json["user"] = git_user
|
git_gate_json["user"] = git_user
|
||||||
if git_gate_json:
|
if git_gate_json:
|
||||||
bottle_json["git-gate"] = git_gate_json
|
bottle_json["git-gate"] = git_gate_json
|
||||||
if supervise:
|
|
||||||
bottle_json["supervise"] = True
|
|
||||||
index = ManifestIndex.from_json_obj({
|
index = ManifestIndex.from_json_obj({
|
||||||
"bottles": {"dev": bottle_json},
|
"bottles": {"dev": bottle_json},
|
||||||
"agents": {
|
"agents": {
|
||||||
@@ -127,13 +124,11 @@ def _plan(
|
|||||||
copy_cwd=copy_cwd,
|
copy_cwd=copy_cwd,
|
||||||
user_cwd=user_cwd,
|
user_cwd=user_cwd,
|
||||||
)
|
)
|
||||||
supervise_plan = None
|
supervise_plan = SupervisePlan(
|
||||||
if supervise:
|
slug="demo-abc12",
|
||||||
supervise_plan = SupervisePlan(
|
queue_dir=Path("/tmp/queue"),
|
||||||
slug="demo-abc12",
|
current_config_dir=Path("/tmp/current-config"),
|
||||||
queue_dir=Path("/tmp/queue"),
|
)
|
||||||
current_config_dir=Path("/tmp/current-config"),
|
|
||||||
)
|
|
||||||
return SmolmachinesBottlePlan(
|
return SmolmachinesBottlePlan(
|
||||||
spec=spec,
|
spec=spec,
|
||||||
manifest=manifest,
|
manifest=manifest,
|
||||||
@@ -405,7 +400,7 @@ class TestBundleLaunchSpec(unittest.TestCase):
|
|||||||
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
spec = _bundle_launch_spec(plan, "net", "127.0.0.16")
|
||||||
|
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
"egress,git-gate,git-http",
|
"egress,supervise,git-gate,git-http",
|
||||||
spec.daemons_csv,
|
spec.daemons_csv,
|
||||||
)
|
)
|
||||||
self.assertIn(9420, spec.ports_to_publish)
|
self.assertIn(9420, spec.ports_to_publish)
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
archive_proposal,
|
archive_proposal,
|
||||||
audit_log_path,
|
audit_log_path,
|
||||||
list_pending_proposals,
|
list_pending_proposals,
|
||||||
@@ -320,6 +321,7 @@ class TestToolConstants(unittest.TestCase):
|
|||||||
supervise.TOOL_ALLOW,
|
supervise.TOOL_ALLOW,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
supervise.TOOL_EGRESS_BLOCK,
|
supervise.TOOL_EGRESS_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
supervise.TOOL_LIST_EGRESS_ROUTES,
|
supervise.TOOL_LIST_EGRESS_ROUTES,
|
||||||
),
|
),
|
||||||
supervise.TOOLS,
|
supervise.TOOLS,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from bot_bottle.supervise import (
|
|||||||
STATUS_MODIFIED,
|
STATUS_MODIFIED,
|
||||||
STATUS_REJECTED,
|
STATUS_REJECTED,
|
||||||
TOOL_CAPABILITY_BLOCK,
|
TOOL_CAPABILITY_BLOCK,
|
||||||
|
TOOL_GITLEAKS_ALLOW,
|
||||||
read_audit_entries,
|
read_audit_entries,
|
||||||
read_response,
|
read_response,
|
||||||
sha256_hex,
|
sha256_hex,
|
||||||
@@ -33,6 +34,7 @@ def _proposal(slug: str = "dev", tool: str = TOOL_CAPABILITY_BLOCK) -> Proposal:
|
|||||||
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
TOOL_CAPABILITY_BLOCK: "FROM python:3.13\n",
|
||||||
supervise.TOOL_ALLOW: "routes:\n - host: example.com\n",
|
supervise.TOOL_ALLOW: "routes:\n - host: example.com\n",
|
||||||
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
supervise.TOOL_EGRESS_BLOCK: "routes:\n - host: example.com\n",
|
||||||
|
TOOL_GITLEAKS_ALLOW: "file: tests/test_fixture.py\nline: 3\n",
|
||||||
}
|
}
|
||||||
payload = payloads.get(tool, "")
|
payload = payloads.get(tool, "")
|
||||||
return Proposal.new(
|
return Proposal.new(
|
||||||
@@ -170,6 +172,30 @@ class TestApproveReject(_FakeHomeMixin, unittest.TestCase):
|
|||||||
self.assertEqual(STATUS_APPROVED, entries[0].operator_action)
|
self.assertEqual(STATUS_APPROVED, entries[0].operator_action)
|
||||||
self.assertEqual("needed for dev", entries[0].justification)
|
self.assertEqual("needed for dev", entries[0].justification)
|
||||||
|
|
||||||
|
def test_approve_gitleaks_allow_leaves_response_for_gate(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
supervise_cli.approve(qp, notes="dummy fixture")
|
||||||
|
# Gate polls the queue dir for the response; TUI must not archive it.
|
||||||
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
|
self.assertEqual(STATUS_APPROVED, resp.status)
|
||||||
|
self.assertEqual("dummy fixture", resp.notes)
|
||||||
|
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||||
|
|
||||||
|
def test_tui_gitleaks_allow_requires_reason(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
with patch.object(supervise_cli, "_prompt", return_value=""):
|
||||||
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
|
self.assertEqual("approve aborted (empty reason)", status)
|
||||||
|
self.assertFalse((qp.queue_dir / "processed").exists())
|
||||||
|
|
||||||
|
def test_tui_gitleaks_allow_writes_reason(self):
|
||||||
|
qp = self._enqueue(tool=TOOL_GITLEAKS_ALLOW)
|
||||||
|
with patch.object(supervise_cli, "_prompt", return_value="test fixture"):
|
||||||
|
status = supervise_cli._approve_from_tui(None, qp) # type: ignore[arg-type]
|
||||||
|
self.assertIn("approved gitleaks-allow", status)
|
||||||
|
resp = read_response(qp.queue_dir, qp.proposal.id)
|
||||||
|
self.assertEqual("test fixture", resp.notes)
|
||||||
|
|
||||||
|
|
||||||
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
# class TestCapabilityApplyWiring(_FakeHomeMixin, unittest.TestCase):
|
||||||
# # DISABLED — capability_apply functionality is currently commented out.
|
# # DISABLED — capability_apply functionality is currently commented out.
|
||||||
|
|||||||
Reference in New Issue
Block a user