diff --git a/integrations/isolation/acq-kits/README.md b/integrations/isolation/acq-kits/README.md index b37b417a..d7aab660 100644 --- a/integrations/isolation/acq-kits/README.md +++ b/integrations/isolation/acq-kits/README.md @@ -53,6 +53,18 @@ vocabulary: backend maps these onto its native env mechanism (sbx `environment.variables`; msb `--env NAME=value`). **Secrets do NOT go here** — they flow through the backend credential/secret path (`acq secret …`), never the kit spec. +- `serviceGateways` — kit-declared, `acq`-managed service gateways. v1 uses + kit-local Compose files only (`runtime.compose.files[]`) and a required + Compose service selector (`runtime.compose.service`), then exposes the + resolved gateway URL to the sandbox/agent via `expose.env` (for example, + `WEB_GATEWAY_URL: url`). Existing `files[]` and `commands[]` remain responsible + for client-side software/config inside the sandbox. The goal is not broader + sandbox egress or API keys in the agent; it is a narrow, policy-controlled + gateway endpoint. Local kit sources are allowed for development/private kits; + remote service-gateway kits must come from trusted kit sources, but v1 does not + add a machine-readable trusted-source gate to the schema or validator. The + schema intentionally excludes host/guest IPs, DNS, VSOCK, Podman-machine, + Kubernetes, and backend-specific routing details. - `backend_shortcuts.` — a native primitive that replaces the declarative path for one backend (e.g. msb's `--trust-host-cas` for the Zscaler kit). Adapters check this first; if present, `caps`/`files`/`commands` diff --git a/integrations/isolation/acq-kits/validate-kits.py b/integrations/isolation/acq-kits/validate-kits.py index 22e6d43f..9bf08e69 100644 --- a/integrations/isolation/acq-kits/validate-kits.py +++ b/integrations/isolation/acq-kits/validate-kits.py @@ -11,13 +11,27 @@ plain string (defense-in-depth over the schema pattern: env vars reach the guest environment and possibly a shell, so a bad name is reported explicitly rather than only failing the schema's additionalProperties rule) + - every serviceGateways[].runtime.compose.files[] entry is kit-local, exists, + and stays under the kit directory + - service-gateway Compose files do not declare container escape primitives + (host networking/namespaces, privileged/capability/device grants, broad host + mounts, Docker socket mounts, unconfined security options, or local builds) + - service-gateway Compose files do not use include: or extends.file to pull in + uninspected Compose content + - serviceGateways[].runtime.compose.service names an existing Compose service + - serviceGateways[].interface.port is present unless that named Compose service + exposes a single unambiguous service port + - serviceGateways[].expose.env maps valid env var names to the resolved URL + token (`url`) - a README.md exists (parity note lives there) It also emits WARN-level advisories (non-fatal by default) for likely re-home -regressions the schema can't catch: +regressions and review signals the schema can't catch: - a commands[] argv that references a kit-provided script/payload path - (a /home/... .sh / .mjs / .py / .crt) that no files[] entry drops. + (a /home/... .sh / .mjs / .py / .crt) that no files[] entry drops + - a service-gateway Compose image that is untagged or uses the floating + `latest` tag. Usage: python integrations/isolation/acq-kits/validate-kits.py [--root .] @@ -85,6 +99,28 @@ # Valid backing-storage types for a volume (mirrors the schema enum). _VOL_TYPES = {"", "tmpfs"} +# serviceGateways[].runtime.compose.files[] are relative kit-local YAML files. +# Keep the path grammar shell/YAML-safe and reject absolute, parent-traversal, +# workspace-referenced, or backend-specific locations in validate_kit. +_COMPOSE_FILE_RE = re.compile(r"^(?!/)(?!.*(?:^|/)\.\.(?:/|$))[A-Za-z0-9._/-]+\.ya?ml$") + +# serviceGateways[].runtime.compose.service names a Compose service. +_COMPOSE_SERVICE_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$") + +# serviceGateways[].interface.protocol values (mirrors the schema enum). +_GATEWAY_PROTOCOLS = {"http", "https", "tcp"} + +# Compose accepts these case-insensitive string values wherever it accepts a +# boolean. Treat every truthy spelling as privileged rather than relying on +# PyYAML's representation of the source value. +_COMPOSE_TRUE = frozenset({"true", "yes", "on", "y"}) +_COMPOSE_VAR_RE = re.compile(r"\$[A-Za-z_][A-Za-z0-9_]*") + +# Docker/OCI image references without an explicit tag or digest, or with the +# floating latest tag, should be reviewed. This is a warning rather than a schema +# error because private registries and local development tags vary. +_DIGEST_RE = re.compile(r"@[A-Za-z0-9_+.-]+:[A-Fa-f0-9]{32,}$") + # Extensions that indicate a kit-provided payload/script a command expects to # have been dropped by files[] (as opposed to a base-image binary, a runtime- # generated file, or a system destination path). @@ -115,6 +151,243 @@ def _is_valid_port(value: object) -> bool: return isinstance(value, int) and not isinstance(value, bool) and 1 <= value <= 65535 +def _compose_services(compose_doc: object) -> dict: + """Return the services mapping from a Compose document, or an empty mapping.""" + if not isinstance(compose_doc, dict): + return {} + services = compose_doc.get("services") + return services if isinstance(services, dict) else {} + + +def _compose_service_candidate_ports(compose_doc: object, service_name: str) -> set[int]: + """Return internal service ports exposed by one named Compose service.""" + service = _compose_services(compose_doc).get(service_name) + if not isinstance(service, dict): + return set() + ports: set[int] = set() + values = service.get("expose") or [] + if isinstance(values, (str, int)): + values = [values] + if not isinstance(values, list): + return ports + for value in values: + port = _parse_compose_port(value) + if port is not None: + ports.add(port) + return ports + + +def _compose_published_port_errors(compose_doc: object) -> list[str]: + """Return errors for host-published ports in a service-gateway Compose file.""" + errors: list[str] = [] + for service_name, service in _compose_services(compose_doc).items(): + if isinstance(service, dict) and service.get("ports"): + errors.append( + f"Compose service {service_name!r} declares ports; service gateways must not publish host ports " + "from Compose. Use expose: for internal service ports or serviceGateways[].interface.port." + ) + return errors + + +def _compose_indirection_errors(compose_doc: object) -> list[str]: + """Return errors for Compose features that pull in uninspected local files.""" + errors: list[str] = [] + if isinstance(compose_doc, dict) and "include" in compose_doc: + errors.append("Compose include: is not supported for service gateways; list kit-local files explicitly") + for service_name, service in _compose_services(compose_doc).items(): + if not isinstance(service, dict): + continue + extends = service.get("extends") + if isinstance(extends, dict) and "file" in extends: + errors.append( + f"Compose service {service_name!r} declares extends.file; service gateways must not pull in " + "uninspected Compose files" + ) + return errors + + +def _as_list(value: object) -> list[object]: + """Return a Compose scalar-or-list field as a list for shallow inspection.""" + if value is None: + return [] + if isinstance(value, list): + return value + return [value] + + +def _volume_source(value: object) -> str | None: + """Return a Compose volume source for common short/long syntax forms.""" + if isinstance(value, str): + parts = value.split(":", 1) + if len(parts) < 2: + return None + return parts[0] + if isinstance(value, dict): + source = value.get("source") or value.get("src") + return source if isinstance(source, str) else None + return None + + +def _has_compose_interpolation(value: object) -> bool: + """True when a Compose field uses env interpolation that validation cannot resolve.""" + if isinstance(value, str): + # Compose treats $$ as a literal dollar sign. Remove escaped dollars + # before detecting either braced (${VAR}) or bare ($VAR) interpolation. + unescaped = value.replace("$$", "") + return "${" in unescaped or _COMPOSE_VAR_RE.search(unescaped) is not None + if isinstance(value, list): + return any(_has_compose_interpolation(item) for item in value) + if isinstance(value, dict): + return any(_has_compose_interpolation(item) for item in value.values()) + return False + + +def _is_compose_true(value: object) -> bool: + """True when Compose will coerce a boolean field to true.""" + return value is True or (isinstance(value, str) and value.strip().lower() in _COMPOSE_TRUE) + + +def _compose_file_reference_errors(compose_doc: object) -> list[str]: + """Return errors for Compose features that can read uninspected local files.""" + errors: list[str] = [] + for service_name, service in _compose_services(compose_doc).items(): + if not isinstance(service, dict): + continue + if service.get("env_file"): + errors.append( + f"Compose service {service_name!r} declares env_file; service gateways must not read local env files" + ) + if not isinstance(compose_doc, dict): + return errors + for section in ("secrets", "configs"): + entries = compose_doc.get(section) or {} + if not isinstance(entries, dict): + continue + for name, entry in entries.items(): + if isinstance(entry, dict) and "file" in entry: + errors.append( + f"Compose {section[:-1]} {name!r} declares file; service gateways must not read local " + f"files via {section}" + ) + return errors + + +def _compose_escape_errors(compose_doc: object, compose_path: Path, kit_dir: Path) -> list[str]: + """Return errors for container escape primitives outside the gateway vocabulary.""" + errors: list[str] = [] + for service_name, service in _compose_services(compose_doc).items(): + if not isinstance(service, dict): + continue + prefix = f"Compose service {service_name!r}" + if _is_compose_true(service.get("privileged")): + errors.append(f"{prefix} declares privileged: true") + elif _has_compose_interpolation(service.get("privileged")): + errors.append(f"{prefix} declares privileged with Compose interpolation; validation cannot prove it safe") + for hook_name in ("post_start", "pre_stop"): + for hook in _as_list(service.get(hook_name)): + if not isinstance(hook, dict): + continue + hook_prefix = f"{prefix} {hook_name} hook" + if _is_compose_true(hook.get("privileged")): + errors.append(f"{hook_prefix} declares privileged: true") + elif _has_compose_interpolation(hook.get("privileged")): + errors.append( + f"{hook_prefix} declares privileged with Compose interpolation; validation cannot prove it safe" + ) + if "build" in service: + errors.append(f"{prefix} declares build; service gateways must use prebuilt reviewed images") + for key in ("network_mode", "pid", "ipc", "userns_mode", "cgroup"): + value = service.get(key) + if value == "host": + errors.append(f"{prefix} declares {key}: host") + elif _has_compose_interpolation(value): + errors.append(f"{prefix} declares {key} with Compose interpolation; validation cannot prove it safe") + if service.get("cap_add"): + errors.append(f"{prefix} declares cap_add; added Linux capabilities are outside serviceGateways v1") + if _has_compose_interpolation(service.get("cap_add")): + errors.append(f"{prefix} declares cap_add with Compose interpolation; validation cannot prove it safe") + if service.get("devices"): + errors.append(f"{prefix} declares devices; host device passthrough is outside serviceGateways v1") + if _has_compose_interpolation(service.get("devices")): + errors.append(f"{prefix} declares devices with Compose interpolation; validation cannot prove it safe") + if service.get("device_cgroup_rules"): + errors.append(f"{prefix} declares device_cgroup_rules; device access rules are outside serviceGateways v1") + if _has_compose_interpolation(service.get("device_cgroup_rules")): + errors.append( + f"{prefix} declares device_cgroup_rules with Compose interpolation; validation cannot prove it safe" + ) + if service.get("group_add"): + errors.append(f"{prefix} declares group_add; supplementary host groups are outside serviceGateways v1") + if _has_compose_interpolation(service.get("group_add")): + errors.append(f"{prefix} declares group_add with Compose interpolation; validation cannot prove it safe") + if _has_compose_interpolation(service.get("build")): + errors.append(f"{prefix} declares build with Compose interpolation; validation cannot prove it safe") + for opt in _as_list(service.get("security_opt")): + if _has_compose_interpolation(opt): + errors.append( + f"{prefix} declares security_opt with Compose interpolation; validation cannot prove it safe" + ) + elif isinstance(opt, str) and ( + "unconfined" in opt or opt.startswith("seccomp=") or opt.startswith("apparmor=") + ): + errors.append(f"{prefix} disables container confinement with security_opt: {opt!r}") + for volume in _as_list(service.get("volumes")): + if _has_compose_interpolation(volume): + errors.append(f"{prefix} declares volume with Compose interpolation; validation cannot prove it safe") + continue + source = _volume_source(volume) + if not source: + continue + if "docker.sock" in source: + errors.append(f"{prefix} mounts the Docker socket: {source!r}") + continue + if source.startswith("/") or source.startswith("~"): + errors.append(f"{prefix} declares a host path mount outside the kit: {source!r}") + continue + if source.startswith("."): + source_path = (compose_path.parent / source).resolve() + try: + source_path.relative_to(kit_dir.resolve()) + except ValueError: + errors.append(f"{prefix} declares a volume source outside the kit: {source!r}") + return errors + + +def _parse_compose_port(value: object) -> int | None: + """Parse the container/service-side port from common Compose port forms.""" + if isinstance(value, int) and not isinstance(value, bool): + return value if _is_valid_port(value) else None + if isinstance(value, str): + candidate = value.split("/", 1)[0].rsplit(":", 1)[-1] + if candidate.isdigit(): + port = int(candidate) + return port if _is_valid_port(port) else None + if isinstance(value, dict): + target = value.get("target") + if isinstance(target, int) and not isinstance(target, bool) and _is_valid_port(target): + return target + return None + + +def _service_images(compose_doc: object) -> list[str]: + """Return image references declared by Compose services.""" + images: list[str] = [] + for service in _compose_services(compose_doc).values(): + if isinstance(service, dict) and isinstance(service.get("image"), str): + images.append(service["image"]) + return images + + +def _uses_floating_image(image: str) -> bool: + """True when an image is untagged or explicitly tagged latest.""" + if _DIGEST_RE.search(image): + return False + last_segment = image.rsplit("/", 1)[-1] + if ":" not in last_segment: + return True + return last_segment.rsplit(":", 1)[-1] == "latest" + + def validate_kit(kit_dir: Path, schema: dict) -> tuple[list[str], list[str]]: """Return (errors, warnings) for one kit.""" errors: list[str] = [] @@ -178,6 +451,144 @@ def validate_kit(kit_dir: Path, schema: dict) -> tuple[list[str], list[str]]: f"{kit_dir.name}: environment['{name}']: value must be a string (got {type(value).__name__})" ) + service_gateways = spec.get("serviceGateways") + if service_gateways is not None: + if not isinstance(service_gateways, list): + errors.append(f"{kit_dir.name}: serviceGateways must be an array of gateway objects") + else: + gateway_names = [ + g.get("name") for g in service_gateways if isinstance(g, dict) and isinstance(g.get("name"), str) + ] + for dup in sorted({name for name in gateway_names if gateway_names.count(name) > 1}): + errors.append( + f"{kit_dir.name}: serviceGateways: duplicate name {dup!r} " + f"(declared {gateway_names.count(dup)} times)" + ) + for i, gateway in enumerate(service_gateways): + if not isinstance(gateway, dict): + errors.append(f"{kit_dir.name}: serviceGateways[{i}] must be an object") + continue + name = gateway.get("name") + if not isinstance(name, str) or not re.match(r"^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$", name): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].name must be kebab-case, 1..64 chars") + interface = gateway.get("interface") or {} + if not isinstance(interface, dict): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].interface must be an object") + interface = {} + protocol = interface.get("protocol") + if protocol not in _GATEWAY_PROTOCOLS: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].interface.protocol must be one of " + f"{sorted(_GATEWAY_PROTOCOLS)} (got {protocol!r})" + ) + if "port" in interface and not _is_valid_port(interface["port"]): + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].interface.port must be an integer 1..65535 " + f"(got {interface['port']!r})" + ) + + runtime = gateway.get("runtime") or {} + compose = (runtime.get("compose") or {}) if isinstance(runtime, dict) else {} + if not isinstance(compose, dict): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose must be an object") + continue + compose_service = compose.get("service") + if not isinstance(compose_service, str) or not _COMPOSE_SERVICE_RE.match(compose_service): + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.service must be a safe " + "Compose service name, 1..64 chars" + ) + compose_service = "" + compose_files = compose.get("files") or [] + if not isinstance(compose_files, list): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.files must be an array") + compose_files = [] + elif not compose_files: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.files must list at least one file" + ) + + candidate_ports: set[int] = set() + inspected_compose = False + found_named_service = False + for rel in compose_files: + if not isinstance(rel, str) or not _COMPOSE_FILE_RE.match(rel) or rel.startswith("/"): + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.files[] must be a relative " + f"kit-local .yaml/.yml path in the safe charset (got {rel!r})" + ) + continue + compose_path = (kit_dir / rel).resolve() + try: + compose_path.relative_to(kit_dir.resolve()) + except ValueError: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.files[] must stay under the kit " + f"directory (got {rel!r})" + ) + continue + if not compose_path.exists(): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.files[] not found: {rel}") + continue + try: + compose_doc = yaml.safe_load(compose_path.read_text()) + except yaml.YAMLError as e: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel} is not valid YAML: {e}" + ) + continue + inspected_compose = True + for detail in _compose_indirection_errors(compose_doc): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel}: {detail}") + for detail in _compose_escape_errors(compose_doc, compose_path, kit_dir): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel}: {detail}") + for detail in _compose_file_reference_errors(compose_doc): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel}: {detail}") + for detail in _compose_published_port_errors(compose_doc): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel}: {detail}") + if compose_service and compose_service in _compose_services(compose_doc): + found_named_service = True + candidate_ports.update(_compose_service_candidate_ports(compose_doc, compose_service)) + for image in _service_images(compose_doc): + if _uses_floating_image(image): + warnings.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose file {rel} uses a floating " + f"image tag: {image!r} (pin a stable tag or digest)" + ) + + if inspected_compose and compose_service and not found_named_service: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].runtime.compose.service {compose_service!r} " + "was not found in any referenced Compose file" + ) + if found_named_service and "port" not in interface and len(candidate_ports) != 1: + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].interface.port is required when Compose service " + f"{compose_service!r} exposes {len(candidate_ports)} candidate ports" + ) + + expose = gateway.get("expose") or {} + if not isinstance(expose, dict): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].expose must be an object") + expose = {} + expose_env = expose.get("env") or {} + if not isinstance(expose_env, dict): + errors.append(f"{kit_dir.name}: serviceGateways[{i}].expose.env must be a mapping of NAME -> url") + elif not expose_env: + errors.append(f"{kit_dir.name}: serviceGateways[{i}].expose.env must expose at least one env var") + else: + for env_name, value in expose_env.items(): + if not _ENV_NAME_RE.match(str(env_name)): + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].expose.env: invalid env var name " + f"'{env_name}' (must match [A-Za-z_][A-Za-z0-9_]*)" + ) + if value != "url": + errors.append( + f"{kit_dir.name}: serviceGateways[{i}].expose.env['{env_name}'] must be " + f"the resolved-value token 'url' (got {value!r})" + ) + if not (kit_dir / "README.md").exists(): errors.append(f"{kit_dir.name}: missing README.md (parity note required)") @@ -316,7 +727,9 @@ def main(argv: list[str] | None = None) -> int: schema = json.loads(schema_path.read_text()) jsonschema.Draft202012Validator.check_schema(schema) - kit_dirs = sorted(d for d in kits_dir.iterdir() if d.is_dir()) if kits_dir.exists() else [] + kit_dirs = ( + sorted(d for d in kits_dir.iterdir() if d.is_dir() and d.name != "__pycache__") if kits_dir.exists() else [] + ) if not kit_dirs: print(f"No kits found under {kits_dir}") return 0 diff --git a/integrations/isolation/docs/decisions/0003-service-gateways-vocabulary.md b/integrations/isolation/docs/decisions/0003-service-gateways-vocabulary.md new file mode 100644 index 00000000..69a77f54 --- /dev/null +++ b/integrations/isolation/docs/decisions/0003-service-gateways-vocabulary.md @@ -0,0 +1,182 @@ +--- +title: "Add neutral serviceGateways vocabulary for acq-managed service gateways" +status: "accepted" +date: "2026-09-15" +decision_makers: ["Bret Mogilefsky", "OpenCode Agent"] +category: "isolation-kit-schema" +impact_level: "moderate" +nist_controls: ["AC-4", "CM-7", "SC-7", "SI-10"] +--- + +# ADR 0003 (isolation) - Add neutral `serviceGateways` vocabulary + +> Area-scoped ADR for `integrations/isolation/`. Extends the neutral `hybrid/v1` +> kit spec (ADR 0001) with a backend-agnostic way for kits to declare companion +> service gateways that `acq` manages outside normal client-side kit setup. + +## Context and Problem Statement + +Some agent workflows need a companion service process that is not simply +software installed inside the sandbox. Examples include a vetted web +search/fetch gateway that may need broader egress than the sandbox itself, or a +Jentic-style policy and credential gateway where real credentials and allowed +operations are enforced outside the agent. The sandboxed agent receives only a +narrow gateway endpoint, not broad network access or API keys. + +Today, `hybrid/v1` has vocabulary for guest files, lifecycle commands, +environment variables, volumes, ports, and backend-specific escape hatches, but +it has no neutral way to say: "this kit also needs an `acq`-managed service +gateway." + +Without a neutral field, authors would be pushed toward backend-specific keys, +ad hoc startup commands, or schema fields that encode one current runtime shape. +That would make service-gateway kits difficult to review, difficult to stop or +remove reliably, and hard to map to future backends. + +## Decision Drivers + +- Keep the kit schema neutral across current and future isolation backends. +- Let kits declare service-gateway intent without encoding host/guest transport + details that belong to `acq` and the active backend. +- Make local development and private-kit workflows possible without weakening the + trust model for remotely fetched service-gateway kits. +- Keep v1 implementation deliberately narrow: Docker Compose is an implementation + detail of the first runtime, not the semantic contract. +- Preserve explicit lifecycle ownership: `acq stop` stops gateways, and `acq rm` + removes gateways and their state. + +## Decision + +Add a top-level `serviceGateways` list to the `hybrid/v1` kit vocabulary. The +field is intentionally named for the semantic role, not the first implementation: +the kit declares service gateways that are managed by `acq` for the sandbox. + +Each entry identifies a gateway, how the current v1 runtime starts it, and the +interface the sandbox consumes. The vocabulary must stay backend-neutral: + +- **Name:** use `serviceGateways`, not `gateways`, `sidecars`, `services`, or a + backend-specific name. The term is explicit enough to distinguish these from + ordinary kit files/commands while avoiding a commitment to Docker, Podman, + Kubernetes, VSOCK, or a particular network topology. +- **Runtime:** `runtime.compose` is the v1 runtime shape. It references only + Compose files that live inside the kit and names the Compose service that is + the gateway. It must not reference files from the user's workspace or other + mutable workspace paths. +- **Sources:** local kit sources remain allowed for development and private kits. + Remote service-gateway kits must come from trusted kit sources; fetching an + untrusted kit that can start companion services is not acceptable. v1 does not + add a machine-readable trusted-source gate to the kit schema or validator; + source trust remains a human review and consumer-configuration decision. +- **Privileges:** service gateways must not require privileged containers, + container escape primitives, broad host mounts, Docker socket access, host + namespaces, extra Linux capabilities, host devices, disabled confinement, + local image builds, local file-reference features, or unresolved Compose + interpolation in high-risk fields. A gateway that needs those primitives is + outside this vocabulary and requires a different reviewed design. +- **Images:** floating image tags are allowed only as an authoring warning, not a + schema error. Validators and reviewers should warn on tags such as `latest` or + on untagged image references so authors pin a stable image or digest. +- **Lifecycle:** `acq stop` stops service gateways. `acq rm` removes service + gateways and their gateway-owned state. Gateway lifecycle is not left to each + kit's arbitrary shell snippets. +- **Resolved endpoint exposure:** v1 supports injecting the resolved gateway URL + into the sandbox/agent environment via `expose.env`, for example + `WEB_GATEWAY_URL: url`. This is the semantic contract: the agent gets a narrow + policy-controlled endpoint. The kit does not broaden sandbox egress and does + not hand API keys to the agent. +- **Client-side setup:** existing kit files and commands continue to own + client-side software installation and configuration inside the sandbox. + `serviceGateways` is only for the managed service side of the boundary. +- **Interface ports:** `interface.port` is optional when the named Compose + service has exactly one internal `expose:` candidate port. It is required when + the named service has zero or multiple exposed candidate ports. Host-published + Compose `ports:` are not valid endpoint evidence for service gateways and are + rejected in gateway Compose files. Ports on other Compose services do not + identify the gateway interface. +- **Excluded schema details:** the kit schema must not include host/guest IPs, + DNS rules, VSOCK details, Podman-machine wiring, Kubernetes resources, or + backend-specific routing. Those are adapter/runtime concerns. +- **Co-location:** even though `runtime.compose` is the first v1 mechanism, the + vocabulary must not assume the sandbox, gateway, and `acq` process are + co-located or local. A future backend might run the service in Kubernetes or + another managed environment while preserving the same semantic contract. + +## Expected v1 Shape + +The schema should remain minimal. A service gateway entry should include: + +- `name` - stable kit-local gateway identifier. +- `runtime.compose.files[]` - one or more kit-local Compose files. +- `runtime.compose.service` - the safe Compose service name that represents the + gateway in the referenced Compose files. +- `interface` - the service interface `acq` resolves, with an optional `port` and + a stable protocol label. +- `expose.env` - a map of sandbox/agent environment variable names to the resolved + gateway value to inject. v1 supports `url` as the exposed value, for example + `WEB_GATEWAY_URL: url`. + +The schema should validate the shape and safe path/name/port basics. The repo +validator should add field-level errors for unsafe or non-kit-local Compose +paths, empty `runtime.compose.files`, missing named services, host-published +Compose ports, file indirection through `include:` or `extends.file`, local +file-reference features such as `env_file` / `secrets.file` / `configs.file`, +unresolved interpolation in high-risk fields, and common container escape +primitives. It should warn for floating image tags it can detect in referenced +Compose files. + +## Consequences + +### Positive + +- Kit authors get one neutral place to declare acq-managed service gateways. +- `acq` gets a clear lifecycle contract for stop/remove behavior rather than + trying to infer gateway state from arbitrary kit commands. +- Compose can ship as the v1 implementation without freezing Compose, local + networking, or a co-located process model into the long-term vocabulary. +- Existing kit files/commands remain focused on client-side guest setup. +- Future backends can implement the same gateway intent with different native + primitives without changing the semantic field name or kit contract. + +### Negative / residual + +- Compose parsing in the patterns-side validator is necessarily shallow unless we + add a full Compose parser, which this decision avoids. The validator rejects + known high-risk primitives and fails closed on Compose file indirection, but + human review remains the control for nuanced Compose semantics. +- Local kit sources are useful for development and private kits, but they widen + the trust decision. Users must treat local service-gateway kits as executable + local code. +- Warning on floating image tags is not a hard schema failure, so maintainers must + keep strict validation and review pressure on gateway images. + +## Alternatives Considered + +- **Name the field `gateways`.** Rejected because it is too broad and could be + confused with network routing gateways. `serviceGateways` says these are + service processes declared by kits and managed by `acq`. +- **Name the field `sidecars`.** Rejected because sidecar implies co-location with + a workload and commonly maps to Kubernetes. The semantic contract must not + assume that topology. +- **Model Compose directly as top-level schema.** Rejected because Compose is only + the v1 runtime mechanism. The long-term contract is a service gateway, not a + Docker Compose application. +- **Require `interface.port` for every gateway.** Rejected because a single + Compose service with a single internal `expose:` port can be unambiguous. + Validators should require it only when the interface cannot be inferred safely. +- **Put IPs, DNS, VSOCK, Podman machine, Kubernetes, or routing details in the kit + schema.** Rejected because those are backend/runtime concerns and would lock the + neutral contract to current implementations. + +## What an agent must NOT decide unilaterally + +- Whether a remote source is trusted enough to provide service-gateway kits. +- Whether a gateway that needs privileged containers or broad host mounts is safe + to run. +- Whether a floating image tag is acceptable for a federally shipped kit. + +## References + +- ADR 0001 (isolation) - neutral `hybrid/v1` acq-kits spec. +- ADR 0002 (isolation) - neutral network egress tiers. +- Schema: [`schemas/kit-hybrid-v1.schema.json`](../../../../schemas/kit-hybrid-v1.schema.json). +- Tracking: GSA-TTS/agentic-coding-quickstart#472. diff --git a/schemas/kit-hybrid-v1.schema.json b/schemas/kit-hybrid-v1.schema.json index 373ac70f..d86d0920 100644 --- a/schemas/kit-hybrid-v1.schema.json +++ b/schemas/kit-hybrid-v1.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://gsa-tts.github.io/agentic-coding-patterns/schemas/kit-hybrid-v1.schema.json", "title": "acq Neutral Kit Schema (hybrid/v1)", - "description": "Schema for the neutral, backend-agnostic acq kit spec (schemaVersion: \"hybrid/v1\"). Captures the declarative parts of an isolation-sandbox mixin kit (network egress, files to drop, lifecycle commands, agent context) in a vocabulary shared across acq backends (sbx, msb, ppp), plus per-backend shortcuts and extras. See integrations/isolation/docs/decisions/0001-neutral-hybrid-v1-acq-kits.md and the acq-design.md exploration (quickstart repo) for rationale.", + "description": "Schema for the neutral, backend-agnostic acq kit spec (schemaVersion: \"hybrid/v1\"). Captures the declarative parts of an isolation-sandbox mixin kit (network egress, files to drop, lifecycle commands, agent context, and acq-managed service gateways) in a vocabulary shared across acq backends (sbx, msb, ppp), plus per-backend shortcuts and extras. See integrations/isolation/docs/decisions/0001-neutral-hybrid-v1-acq-kits.md, integrations/isolation/docs/decisions/0003-service-gateways-vocabulary.md, and the acq-design.md exploration (quickstart repo) for rationale.", "type": "object", "additionalProperties": false, "required": [ @@ -216,6 +216,92 @@ } } }, + "serviceGateways": { + "type": "array", + "description": "Kit-declared, acq-managed service gateways for the sandbox. The field names the semantic role, not a backend topology: do not encode host/guest IPs, DNS, VSOCK, Podman-machine, Kubernetes, or routing details here. runtime.compose is the v1 runtime shape and may reference only Compose files shipped inside the kit; the repo validator rejects Compose include:/extends.file indirection, local file-reference features, unresolved interpolation in high-risk fields, and high-risk host/container escape primitives. Existing files[] and commands[] remain responsible for client-side software/config in the sandbox. expose.env injects resolved gateway values into the sandbox/agent environment so the agent gets a narrow policy-controlled endpoint, not broader sandbox egress or API keys. acq owns gateway lifecycle: stop stops gateways; rm removes gateways and gateway-owned state. v1 source trust is a human review and consumer-configuration decision, not a schema field.", + "items": { + "type": "object", + "additionalProperties": false, + "required": ["name", "runtime", "interface", "expose"], + "properties": { + "name": { + "type": "string", + "pattern": "^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$", + "description": "Stable kit-local gateway identifier. Kebab-case, 1-64 chars, starts/ends alphanumeric." + }, + "runtime": { + "type": "object", + "additionalProperties": false, + "required": ["compose"], + "properties": { + "compose": { + "type": "object", + "additionalProperties": false, + "required": ["files", "service"], + "properties": { + "service": { + "type": "string", + "pattern": "^[A-Za-z0-9][A-Za-z0-9_.-]{0,63}$", + "description": "Compose service name that represents the gateway in the referenced kit-local Compose files. Required so multi-service Compose files can identify which service acq exposes." + }, + "files": { + "type": "array", + "minItems": 1, + "description": "Compose files for the v1 service-gateway runtime. Every file path is relative to the kit directory and must stay under the kit. Do not reference workspace files. Validators reject Compose include:/extends.file and local file-reference features so this declared list is the inspected file set.", + "items": { + "type": "string", + "pattern": "^(?!/)(?!.*(?:^|/)\\.\\.(?:/|$))[A-Za-z0-9._/-]+\\.ya?ml$" + } + } + } + } + } + }, + "interface": { + "type": "object", + "additionalProperties": false, + "required": ["protocol"], + "properties": { + "protocol": { + "type": "string", + "enum": ["http", "https", "tcp"], + "description": "Stable protocol label for the sandbox-facing service interface. Backend-specific routing remains outside the kit schema." + }, + "port": { + "type": "integer", + "minimum": 1, + "maximum": 65535, + "description": "Optional sandbox-facing service port. May be omitted only when the named Compose service exposes exactly one candidate port; validators require it when the named service exposes zero or multiple candidate ports." + } + } + }, + "expose": { + "type": "object", + "additionalProperties": false, + "required": ["env"], + "description": "Values acq exposes into the sandbox/agent environment after resolving the gateway interface. v1 supports exposing the resolved gateway URL.", + "properties": { + "env": { + "type": "object", + "additionalProperties": false, + "minProperties": 1, + "description": "Map of sandbox/agent environment variable names to the resolved gateway value to inject. Values are symbolic names, not literals; v1 supports only 'url'. Example: WEB_GATEWAY_URL: url.", + "patternProperties": { + "^[A-Za-z_][A-Za-z0-9_]*$": { + "type": "string", + "const": "url", + "description": "Expose the resolved gateway URL in this environment variable." + } + } + } + } + }, + "description": { + "type": "string" + } + } + } + }, "agentContext": { "type": "string", "description": "Markdown context surfaced to the agent (written to the agent context root by each backend)." diff --git a/scripts/tests/test_validate_kits_service_gateways.py b/scripts/tests/test_validate_kits_service_gateways.py new file mode 100644 index 00000000..cc2735c6 --- /dev/null +++ b/scripts/tests/test_validate_kits_service_gateways.py @@ -0,0 +1,732 @@ +"""Tests for the hybrid/v1 serviceGateways vocabulary. + +`serviceGateways` declares kit-provided, acq-managed service gateways without +baking backend routing details into the neutral kit schema. v1 uses kit-local +Compose files only, names the Compose service to expose, and injects the +resolved gateway URL into the sandbox/agent environment through expose.env. The +validator enforces path locality, blocks Compose escape primitives and local-file +indirection, requires explicit ports when the named Compose service is ambiguous, +and emits floating-image warnings. +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import jsonschema +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_PATH = ROOT / "schemas" / "kit-hybrid-v1.schema.json" +VALIDATOR_PATH = ROOT / "integrations" / "isolation" / "acq-kits" / "validate-kits.py" + + +def _load_schema() -> dict: + return json.loads(SCHEMA_PATH.read_text()) + + +def _load_validate_kit(): + spec = importlib.util.spec_from_file_location("validate_kits", VALIDATOR_PATH) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod.validate_kit + + +def _base(**extra) -> dict: + d = { + "schemaVersion": "hybrid/v1", + "kind": "mixin", + "name": "t", + "displayName": "T", + "description": "d", + } + d.update(extra) + return d + + +def _gateway(**extra) -> dict: + gateway = { + "name": "demo-gateway", + "runtime": {"compose": {"files": ["compose.yaml"], "service": "gateway"}}, + "interface": {"protocol": "http"}, + "expose": {"env": {"WEB_GATEWAY_URL": "url"}}, + } + gateway.update(extra) + return gateway + + +def _write_kit(kit_dir: Path, spec_yaml: str, compose_yaml: str | None = None) -> Path: + kit_dir.mkdir(parents=True, exist_ok=True) + (kit_dir / "README.md").write_text("# t\n") + if compose_yaml is not None: + (kit_dir / "compose.yaml").write_text(compose_yaml) + (kit_dir / "spec.yaml").write_text(spec_yaml) + return kit_dir + + +class TestServiceGatewaysSchema: + def test_absent_service_gateways_still_valid(self): + jsonschema.validate(instance=_base(), schema=_load_schema()) + + def test_minimal_valid_gateway_accepted(self): + jsonschema.validate(instance=_base(serviceGateways=[_gateway()]), schema=_load_schema()) + + def test_expose_env_accepted(self): + inst = _base(serviceGateways=[_gateway(interface={"protocol": "http", "port": 8080})]) + jsonschema.validate(instance=inst, schema=_load_schema()) + + def test_compose_env_rejected_as_non_contract_field(self): + inst = _base( + serviceGateways=[ + _gateway( + runtime={"compose": {"files": ["compose.yaml"], "service": "gateway", "env": {"MODE": "dev"}}}, + interface={"protocol": "http", "port": 8080}, + ) + ] + ) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=inst, schema=_load_schema()) + + def test_compose_service_required(self): + inst = _base(serviceGateways=[_gateway(runtime={"compose": {"files": ["compose.yaml"]}})]) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=inst, schema=_load_schema()) + + @pytest.mark.parametrize("bad", ["/workspace/compose.yaml", "../compose.yaml", "compose.json"]) + def test_bad_compose_path_rejected(self, bad): + inst = _base(serviceGateways=[_gateway(runtime={"compose": {"files": [bad], "service": "gateway"}})]) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=inst, schema=_load_schema()) + + def test_backend_routing_fields_rejected(self): + inst = _base(serviceGateways=[_gateway(interface={"protocol": "http", "port": 8080, "hostIp": "127.0.0.1"})]) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=inst, schema=_load_schema()) + + def test_expose_value_must_be_url_token(self): + inst = _base(serviceGateways=[_gateway(expose={"env": {"WEB_GATEWAY_URL": "http://127.0.0.1:8080"}})]) + with pytest.raises(jsonschema.ValidationError): + jsonschema.validate(instance=inst, schema=_load_schema()) + + +class TestServiceGatewaysValidator: + def test_single_compose_port_allows_omitted_interface_port(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "gatewaykit", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: gatewaykit\n" + "displayName: Gateway\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n expose: [8080]\n", + ) + errors, warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + assert warnings == [], warnings + + def test_multiple_exposed_compose_ports_require_interface_port(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "ambiguous", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: ambiguous\n" + "displayName: Ambiguous\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n expose: [8080, 9090]\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("interface.port is required" in e and "2 candidate ports" in e for e in errors), errors + + def test_rejects_host_published_ports_on_any_gateway_compose_service(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "hostports", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: hostports\n" + "displayName: Host Ports\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " metrics:\n" + " image: ghcr.io/example/metrics:1.0.0\n" + " ports:\n" + " - '9090:9090'\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("service 'metrics' declares ports" in e for e in errors), errors + + def test_missing_compose_service_is_error(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "missingservice", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: missingservice\n" + "displayName: Missing Service\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n other:\n image: ghcr.io/example/other:1.0.0\n expose: [8080]\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("runtime.compose.service 'gateway'" in e and "not found" in e for e in errors), errors + + def test_named_service_with_no_ports_requires_interface_port(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "noports", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: noports\n" + "displayName: No Ports\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("service 'gateway' exposes 0 candidate ports" in e for e in errors), errors + + def test_explicit_interface_port_allows_ambiguous_compose_ports(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "explicit", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: explicit\n" + "displayName: Explicit\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n expose: [8080, 9090]\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + + def test_rejects_empty_compose_files(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "emptyfiles", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: emptyfiles\n" + "displayName: Empty Files\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: []\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + None, + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("runtime.compose.files must list at least one file" in e for e in errors), errors + + def test_rejects_missing_nonlocal_and_privileged_compose(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "badgateway", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: badgateway\n" + "displayName: Bad\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [missing.yaml, ../outside.yaml, compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " privileged: true\n" + " expose: [8080]\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("not found: missing.yaml" in e for e in errors), errors + assert any("must be a relative kit-local" in e for e in errors), errors + assert any("privileged: true" in e for e in errors), errors + + @pytest.mark.parametrize("value", ["true", '"true"', '"YES"', '"On"', '"y"']) + def test_rejects_compose_truthy_privileged_values(self, tmp_path, value): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "privileged", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: privileged\n" + "displayName: Privileged\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + f" privileged: {value}\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("privileged: true" in error for error in errors), errors + + @pytest.mark.parametrize("value", ["${PRIVILEGED}", "$PRIVILEGED"]) + def test_rejects_interpolated_privileged_values(self, tmp_path, value): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "interpolated-privileged", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: interpolated-privileged\n" + "displayName: Interpolated Privileged\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + f" privileged: {value}\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("privileged with Compose interpolation" in error for error in errors), errors + + @pytest.mark.parametrize("hook_name", ["post_start", "pre_stop"]) + @pytest.mark.parametrize( + ("value", "expected"), + [("true", "privileged: true"), ("$PRIVILEGED", "privileged with Compose interpolation")], + ) + def test_rejects_privileged_lifecycle_hooks(self, tmp_path, hook_name, value, expected): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "privileged-hook", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: privileged-hook\n" + "displayName: Privileged Hook\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + f" {hook_name}:\n" + " - command: /bin/true\n" + f" privileged: {value}\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any(hook_name in error and expected in error for error in errors), errors + + @pytest.mark.parametrize( + ("compose_yaml", "expected"), + [ + ( + "include: [../outside.yaml]\n" + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n", + "include:", + ), + ( + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " extends:\n" + " file: ../base.yaml\n" + " service: base\n", + "extends.file", + ), + ], + ) + def test_rejects_compose_file_indirection(self, tmp_path, compose_yaml, expected): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "indirection", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: indirection\n" + "displayName: Indirection\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + compose_yaml, + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any(expected in e for e in errors), errors + + @pytest.mark.parametrize( + ("snippet", "expected"), + [ + (" volumes: ['/var/run/docker.sock:/var/run/docker.sock']\n", "Docker socket"), + (" volumes: ['/:/host:ro']\n", "host path mount"), + (" volumes: ['${HOST_ROOT:-/}:/host:ro']\n", "Compose interpolation"), + (" volumes: ['$HOME:/host:ro']\n", "Compose interpolation"), + (" network_mode: host\n", "network_mode: host"), + (" network_mode: ${GW_NETWORK_MODE:-host}\n", "Compose interpolation"), + (" pid: $PIDMODE\n", "Compose interpolation"), + (" pid: host\n", "pid: host"), + (" userns_mode: host\n", "userns_mode: host"), + (" ipc: host\n", "ipc: host"), + (" cgroup: host\n", "cgroup: host"), + (" cap_add: [SYS_ADMIN]\n", "cap_add"), + (" security_opt: ['apparmor:unconfined']\n", "security_opt"), + (" devices: ['/dev/kvm:/dev/kvm']\n", "devices"), + (" device_cgroup_rules: ['b 7:* rmw']\n", "device_cgroup_rules"), + (" device_cgroup_rules: $DEVICE_RULES\n", "device_cgroup_rules with Compose interpolation"), + (" group_add: ['0']\n", "group_add"), + (" group_add: $SUPPLEMENTARY_GROUPS\n", "group_add with Compose interpolation"), + (" build: .\n", "build"), + ], + ) + def test_rejects_compose_escape_primitives(self, tmp_path, snippet, expected): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "escape", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: escape\n" + "displayName: Escape\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + f"services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n expose: [8080]\n{snippet}", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any(expected in e for e in errors), errors + + def test_allows_compose_escaped_dollar_in_volume_source(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "escaped-dollar", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: escaped-dollar\n" + "displayName: Escaped Dollar\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " volumes: ['$$HOME:/container:ro']\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + + def test_allows_compose_escaped_braced_dollar_in_volume_source(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "escaped-braced-dollar", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: escaped-braced-dollar\n" + "displayName: Escaped Braced Dollar\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " port: 8080\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " volumes: ['$${HOME}:/container:ro']\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + + @pytest.mark.parametrize( + ("compose_yaml", "expected"), + [ + ( + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " env_file: /tmp/host.env\n", + "env_file", + ), + ( + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " secrets: [host_secret]\n" + "secrets:\n" + " host_secret:\n" + " file: /tmp/host-secret\n", + "secret 'host_secret' declares file", + ), + ( + "services:\n" + " gateway:\n" + " image: ghcr.io/example/gateway:1.0.0\n" + " expose: [8080]\n" + " configs: [host_config]\n" + "configs:\n" + " host_config:\n" + " file: /tmp/host-config\n", + "config 'host_config' declares file", + ), + ], + ) + def test_rejects_compose_local_file_references(self, tmp_path, compose_yaml, expected): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "filerefs", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: filerefs\n" + "displayName: File Refs\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: http\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + compose_yaml, + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any(expected in e for e in errors), errors + + @pytest.mark.parametrize("image", ["redis", "redis:latest"]) + def test_warns_on_floating_image_tags(self, tmp_path, image): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "floating", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: floating\n" + "displayName: Floating\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: tcp\n" + " port: 6379\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + f"services:\n gateway:\n image: {image}\n expose: [6379]\n", + ) + errors, warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + assert any("floating image tag" in w and image in w for w in warnings), warnings + + def test_accepts_digest_pinned_image_without_warning(self, tmp_path): + validate_kit = _load_validate_kit() + image = "ghcr.io/example/gateway@sha256:" + "a" * 64 + kit = _write_kit( + tmp_path / "digest", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: digest\n" + "displayName: Digest\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: tcp\n" + " port: 6379\n" + " expose:\n" + " env:\n" + " WEB_GATEWAY_URL: url\n", + f"services:\n gateway:\n image: {image}\n expose: [6379]\n", + ) + errors, warnings = validate_kit(kit, _load_schema()) + assert errors == [], errors + assert warnings == [], warnings + + def test_rejects_bad_expose_env(self, tmp_path): + validate_kit = _load_validate_kit() + kit = _write_kit( + tmp_path / "badenv", + "schemaVersion: hybrid/v1\n" + "kind: mixin\n" + "name: badenv\n" + "displayName: Bad Env\n" + "description: d\n" + "serviceGateways:\n" + " - name: demo-gateway\n" + " runtime:\n" + " compose:\n" + " files: [compose.yaml]\n" + " service: gateway\n" + " interface:\n" + " protocol: tcp\n" + " port: 6379\n" + " expose:\n" + " env:\n" + " BAD-NAME: url\n" + " WEB_GATEWAY_URL: literal\n", + "services:\n gateway:\n image: ghcr.io/example/gateway:1.0.0\n expose: [6379]\n", + ) + errors, _warnings = validate_kit(kit, _load_schema()) + assert any("expose.env: invalid env var name 'BAD-NAME'" in e for e in errors), errors + assert any("expose.env['WEB_GATEWAY_URL']" in e and "'url'" in e for e in errors), errors