composable-data-stack 0.13.0__py3-none-any.whl → 0.14.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
cli/confinement.py ADDED
@@ -0,0 +1,219 @@
1
+ """Declarative runtime confinement (seccomp, AppArmor, SELinux) for Compose services.
2
+
3
+ A profile declares a default under `spec.security.runtime.confinement`; a
4
+ module instance may override individual keys under `security.confinement`.
5
+ The effective value is rendered into each service's `security_opt`.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import re
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ from .diagnostics import Diagnostic
15
+
16
+ INVALID_CONFINEMENT_CODE = "E127"
17
+ CONFINEMENT_KEYS = ("seccomp", "apparmor", "selinux")
18
+
19
+ _APPARMOR_NAME_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._/-]{0,127}$")
20
+ _DRIVE_RE = re.compile(r"^[A-Za-z]:")
21
+ _SELINUX_LABEL_RE = re.compile(r"^(user|role|type|level):[A-Za-z0-9_.,:-]+$")
22
+
23
+
24
+ def profile_confinement(profile_or_plan: dict[str, Any] | None) -> dict[str, Any]:
25
+ """Return the profile-wide confinement block from a profile or plan."""
26
+ if not isinstance(profile_or_plan, dict):
27
+ return {}
28
+ security = profile_or_plan.get("security")
29
+ if security is None:
30
+ security = (profile_or_plan.get("spec") or {}).get("security")
31
+ runtime = (security or {}).get("runtime") if isinstance(security, dict) else None
32
+ block = (runtime or {}).get("confinement") if isinstance(runtime, dict) else None
33
+ return block if isinstance(block, dict) else {}
34
+
35
+
36
+ def plan_declares_confinement(plan: dict[str, Any] | None) -> bool:
37
+ """True when the profile or any module instance sets confinement."""
38
+ if profile_confinement(plan):
39
+ return True
40
+ return any(
41
+ isinstance((module.get("security") or {}).get("confinement"), dict)
42
+ and (module.get("security") or {}).get("confinement")
43
+ for module in (plan or {}).get("modules") or []
44
+ if isinstance(module, dict)
45
+ )
46
+
47
+
48
+ def effective_confinement(
49
+ defaults: dict[str, Any] | None, override: dict[str, Any] | None
50
+ ) -> dict[str, Any]:
51
+ """Profile defaults with the module instance's keys layered on top."""
52
+ merged = {key: value for key, value in (defaults or {}).items() if key in CONFINEMENT_KEYS}
53
+ for key, value in (override or {}).items():
54
+ if key in CONFINEMENT_KEYS:
55
+ merged[key] = value
56
+ return merged
57
+
58
+
59
+ def validate_confinement(
60
+ block: Any, path: str, base_dir: Path | None = None
61
+ ) -> list[Diagnostic]:
62
+ """Check one confinement block; `base_dir` anchors `file:` seccomp profiles."""
63
+ if block is None:
64
+ return []
65
+ if not isinstance(block, dict):
66
+ return [_error(f"{path} must be an object.", path)]
67
+ diagnostics: list[Diagnostic] = []
68
+ for key in block:
69
+ if key not in CONFINEMENT_KEYS:
70
+ diagnostics.append(_error(
71
+ f"Unknown confinement key '{key}'; supported keys: {', '.join(CONFINEMENT_KEYS)}.",
72
+ f"{path}.{key}",
73
+ ))
74
+
75
+ seccomp = block.get("seccomp")
76
+ if seccomp is not None:
77
+ diagnostics.extend(_validate_seccomp(seccomp, f"{path}.seccomp", base_dir))
78
+ apparmor = block.get("apparmor")
79
+ if apparmor is not None and not (
80
+ isinstance(apparmor, str)
81
+ and (apparmor in {"runtime/default", "unconfined"} or _APPARMOR_NAME_RE.match(apparmor))
82
+ ):
83
+ diagnostics.append(_error(
84
+ "apparmor must be 'runtime/default', 'unconfined' or the name of a profile "
85
+ "already loaded on the host (letters, digits, '.', '_', '-', '/').",
86
+ f"{path}.apparmor",
87
+ ))
88
+ selinux = block.get("selinux")
89
+ if selinux is not None:
90
+ diagnostics.extend(_validate_selinux(selinux, f"{path}.selinux"))
91
+ return diagnostics
92
+
93
+
94
+ def _validate_seccomp(value: Any, path: str, base_dir: Path | None) -> list[Diagnostic]:
95
+ if value in {"default", "unconfined"}:
96
+ return []
97
+ if not isinstance(value, str) or not value.startswith("file:") or len(value) == 5:
98
+ return [_error(
99
+ "seccomp must be 'default', 'unconfined' or 'file:<path to a JSON profile "
100
+ "relative to the profile directory>'.",
101
+ path,
102
+ )]
103
+ relative = value[5:]
104
+ if base_dir is None:
105
+ return [_error(
106
+ "seccomp file profiles need a known profile directory to resolve against.", path
107
+ )]
108
+ # Reject rooted and drive-qualified paths explicitly: they are not
109
+ # "absolute" to pathlib on every platform (e.g. "/x" on Windows).
110
+ if (
111
+ Path(relative).is_absolute()
112
+ or relative.startswith(("/", "\\"))
113
+ or _DRIVE_RE.match(relative)
114
+ or ".." in Path(relative).parts
115
+ ):
116
+ return [_error(
117
+ "seccomp file profiles must be relative paths inside the profile directory.", path
118
+ )]
119
+ target = (base_dir / relative).resolve()
120
+ if not target.is_relative_to(base_dir.resolve()):
121
+ return [_error(
122
+ "seccomp file profile resolves outside the profile directory.", path
123
+ )]
124
+ if not target.is_file():
125
+ return [_error(f"seccomp profile '{relative}' does not exist.", path)]
126
+ return []
127
+
128
+
129
+ def _validate_selinux(value: Any, path: str) -> list[Diagnostic]:
130
+ if value == "disable":
131
+ return []
132
+ if not isinstance(value, list) or not value or not all(isinstance(v, str) for v in value):
133
+ return [_error(
134
+ "selinux must be 'disable' or a non-empty list of labels such as "
135
+ "'type:container_t' or 'level:s0:c100,c200'.",
136
+ path,
137
+ )]
138
+ bad = [label for label in value if not _SELINUX_LABEL_RE.match(label)]
139
+ if bad:
140
+ return [_error(
141
+ f"Invalid SELinux label(s): {', '.join(bad)}; expected user:, role:, type: or level: values.",
142
+ path,
143
+ )]
144
+ kinds = [label.split(":", 1)[0] for label in value]
145
+ duplicates = sorted({k for k in kinds if kinds.count(k) > 1})
146
+ if duplicates:
147
+ return [_error(
148
+ f"Duplicate SELinux label kind(s): {', '.join(duplicates)}; "
149
+ "each of user:, role:, type: and level: may appear at most once.",
150
+ path,
151
+ )]
152
+ return []
153
+
154
+
155
+ def confinement_security_opts(
156
+ confinement: dict[str, Any], compose_dir: Path, base_dir: Path | None
157
+ ) -> list[str]:
158
+ """Translate a validated confinement block into Compose `security_opt` entries.
159
+
160
+ Raises ValueError when a seccomp file cannot be expressed relative to
161
+ `compose_dir` (for example on a different Windows drive).
162
+ """
163
+ opts: list[str] = []
164
+ seccomp = confinement.get("seccomp")
165
+ if seccomp == "unconfined":
166
+ opts.append("seccomp=unconfined")
167
+ elif isinstance(seccomp, str) and seccomp.startswith("file:"):
168
+ target = (base_dir or compose_dir) / seccomp[5:]
169
+ opts.append(f"seccomp:{Path(os.path.relpath(target, compose_dir)).as_posix()}")
170
+ apparmor = confinement.get("apparmor")
171
+ if apparmor == "runtime/default":
172
+ opts.append("apparmor=docker-default")
173
+ elif isinstance(apparmor, str):
174
+ opts.append(f"apparmor={apparmor}")
175
+ selinux = confinement.get("selinux")
176
+ if selinux == "disable":
177
+ opts.append("label=disable")
178
+ elif isinstance(selinux, list):
179
+ opts.extend(f"label={label}" for label in selinux)
180
+ return opts
181
+
182
+
183
+ def apply_confinement(
184
+ service: dict[str, Any],
185
+ confinement: dict[str, Any],
186
+ compose_dir: Path,
187
+ base_dir: Path | None,
188
+ path: str = "security.confinement",
189
+ ) -> list[Diagnostic]:
190
+ """Set the declared confinement on a service, replacing module-set values per key."""
191
+ if not confinement:
192
+ return []
193
+ try:
194
+ new_opts = confinement_security_opts(confinement, compose_dir, base_dir)
195
+ except ValueError:
196
+ return [_error(
197
+ "seccomp file profile cannot be referenced relative to the generated "
198
+ "docker-compose.yml (different drive); render into the profile's drive.",
199
+ f"{path}.seccomp",
200
+ )]
201
+ existing = [str(opt) for opt in service.get("security_opt") or []]
202
+ replaced = {
203
+ "seccomp": ("seccomp=", "seccomp:"),
204
+ "apparmor": ("apparmor=", "apparmor:"),
205
+ "selinux": ("label=", "label:"),
206
+ }
207
+ prefixes = tuple(p for key in confinement for p in replaced.get(key, ()))
208
+ kept = [opt for opt in existing if not opt.startswith(prefixes)] if prefixes else existing
209
+ # `default` seccomp means "engine default", so it only clears module-set values.
210
+ merged = kept + new_opts
211
+ if merged:
212
+ service["security_opt"] = merged
213
+ else:
214
+ service.pop("security_opt", None)
215
+ return []
216
+
217
+
218
+ def _error(message: str, path: str) -> Diagnostic:
219
+ return Diagnostic(level="error", code=INVALID_CONFINEMENT_CODE, message=message, path=path)
cli/k8s_renderer.py CHANGED
@@ -28,6 +28,7 @@ from typing import Any
28
28
 
29
29
  import yaml
30
30
 
31
+ from .confinement import plan_declares_confinement
31
32
  from .diagnostics import Diagnostic
32
33
  from .renderer import (
33
34
  _build_context,
@@ -37,6 +38,7 @@ from .renderer import (
37
38
  _resolve_profile_dir,
38
39
  _resolve_project_root,
39
40
  _substitute_values,
41
+ apply_production_gates,
40
42
  )
41
43
  from .secret_leak import (
42
44
  check_generated_text,
@@ -110,6 +112,21 @@ def render_helm(
110
112
  path=f"module:{module_id}.implementation.kubernetes",
111
113
  )
112
114
  )
115
+
116
+ if plan_declares_confinement(plan):
117
+ diagnostics.append(
118
+ Diagnostic(
119
+ level="warning",
120
+ code="W127",
121
+ message=(
122
+ "Runtime confinement (seccomp/AppArmor/SELinux) is only rendered for "
123
+ "the compose target; it has no effect on the Helm chart. "
124
+ "Kubernetes security contexts are tracked under #65."
125
+ ),
126
+ path="spec.security.runtime.confinement",
127
+ )
128
+ )
129
+
113
130
  if unsupported:
114
131
  return {}, diagnostics
115
132
 
@@ -117,6 +134,28 @@ def render_helm(
117
134
  # ${k8s.service.*} can point forward to a workload declared later.
118
135
  service_names = _collect_service_names(renderable)
119
136
  diagnostics.extend(_check_binding_services(renderable, service_names))
137
+
138
+ # The built-in backup capability (config.backup.enabled, issue #665) is
139
+ # only implemented for the compose target: there is no Kubernetes
140
+ # sidecar workload yet. Fail closed instead of rendering a chart whose
141
+ # database is silently unprotected.
142
+ for module in renderable:
143
+ backup_config = (module.get("config") or {}).get("backup")
144
+ if isinstance(backup_config, dict) and backup_config.get("enabled") is True:
145
+ diagnostics.append(
146
+ Diagnostic(
147
+ level="error",
148
+ code="E142",
149
+ message=(
150
+ f'Module "{module["id"]}" enables config.backup.enabled, but the '
151
+ 'backup capability is not implemented for the "helm" render target '
152
+ "(no Kubernetes sidecar workload exists yet). Render this profile "
153
+ "with --target=compose, or disable backup for Kubernetes deploys."
154
+ ),
155
+ path=f"module:{module['id']}.config.backup.enabled",
156
+ )
157
+ )
158
+
120
159
  if any(diagnostic.level == "error" for diagnostic in diagnostics):
121
160
  return {}, diagnostics
122
161
 
@@ -141,8 +180,13 @@ def render_helm(
141
180
  "service": _service_context(module, service_names)
142
181
  }
143
182
  _release_scope_binding_hosts(context, service_names)
144
- compose_services = deepcopy(
145
- module["implementation"].get("compose", {}).get("services", {})
183
+ # The helm target is treated as production-only for now: Kubernetes
184
+ # deploys always render the production variants, regardless of the
185
+ # profile's metadata.environment. The learning (easy-install)
186
+ # variants exist for the compose target.
187
+ compose_services = apply_production_gates(
188
+ deepcopy(module["implementation"].get("compose", {}).get("services", {})),
189
+ True,
146
190
  )
147
191
  _merge_init_db_env(compose_services, module, secrets)
148
192
 
@@ -162,6 +206,7 @@ def render_helm(
162
206
  service_names=service_names,
163
207
  profile_dir=profile_dir,
164
208
  project_root=project_root,
209
+ is_production=True,
165
210
  )
166
211
  values["modules"][module_id] = {
167
212
  "config": _values_config(module.get("config", {})),
@@ -219,6 +264,7 @@ def _render_module(
219
264
  service_names: dict[str, str],
220
265
  profile_dir: Path | None,
221
266
  project_root: Path | None,
267
+ is_production: bool = False,
222
268
  ) -> tuple[
223
269
  dict[str, Any],
224
270
  list[tuple[str, dict[str, Any]]],
@@ -232,12 +278,16 @@ def _render_module(
232
278
  secret_keys: set[str] = set()
233
279
  module_values: dict[str, Any] = {}
234
280
 
235
- volume_specs = _substitute_values(deepcopy(k8s.get("volumes", {})), context)
281
+ volume_specs = apply_production_gates(
282
+ _substitute_values(deepcopy(k8s.get("volumes", {})), context), is_production
283
+ )
284
+ configmap_specs = apply_production_gates(
285
+ _substitute_values(deepcopy(k8s.get("configMaps", {})), context), is_production
286
+ )
236
287
  for volume_spec in volume_specs.values():
237
288
  enabled_from = volume_spec.get("enabledFrom")
238
289
  if isinstance(enabled_from, str):
239
290
  volume_spec["enabledFrom"] = _resolve_expr(enabled_from, context)
240
- configmap_specs = _substitute_values(deepcopy(k8s.get("configMaps", {})), context)
241
291
 
242
292
  # ConfigMaps are module-scoped: one manifest, mounted by whichever
243
293
  # containers name it.
@@ -305,6 +355,7 @@ def _render_module(
305
355
  pod_volumes,
306
356
  pvcs,
307
357
  is_init=True,
358
+ is_production=is_production,
308
359
  )
309
360
  secret_keys |= keys
310
361
  init_containers.append(container)
@@ -328,6 +379,7 @@ def _render_module(
328
379
  pod_volumes,
329
380
  pvcs,
330
381
  is_init=False,
382
+ is_production=is_production,
331
383
  )
332
384
  secret_keys |= keys
333
385
  containers.append(container)
@@ -432,6 +484,7 @@ def _translate_container(
432
484
  pod_volumes: dict[str, dict[str, Any]],
433
485
  pvcs: dict[str, dict[str, Any]],
434
486
  is_init: bool,
487
+ is_production: bool = False,
435
488
  ) -> tuple[dict[str, Any], dict, dict, set[str]]:
436
489
  spec = _substitute_values(deepcopy(spec), context)
437
490
  spec.pop("enabledFrom", None)
@@ -495,6 +548,7 @@ def _translate_container(
495
548
  pvcs,
496
549
  overrides.get("dropVolumeMounts", []) or [],
497
550
  context=context,
551
+ is_production=is_production,
498
552
  )
499
553
  if mounts:
500
554
  container["volumeMounts"] = mounts
@@ -730,6 +784,7 @@ def _translate_volumes(
730
784
  pvcs: dict[str, dict[str, Any]],
731
785
  drop_mounts: list[str],
732
786
  context: dict[str, Any] | None = None,
787
+ is_production: bool = False,
733
788
  ) -> list[dict[str, Any]]:
734
789
  mounts: list[dict[str, Any]] = []
735
790
  dropped = {str(d).rstrip("/") for d in drop_mounts}
@@ -745,6 +800,9 @@ def _translate_volumes(
745
800
  mounts.append({"name": name, "mountPath": path})
746
801
 
747
802
  for entry in spec.get("volumes", []) or []:
803
+ if isinstance(entry, dict) and isinstance(entry.get("production"), bool):
804
+ if entry["production"] != is_production:
805
+ continue
748
806
  source, target, read_only, kind = _parse_volume_entry(entry)
749
807
  if target is None or target.rstrip("/") in dropped:
750
808
  continue
cli/planner.py CHANGED
@@ -14,7 +14,9 @@ from .loader import load_yaml_file, resolve_module_file
14
14
  from .resolver import (
15
15
  RequiredIfSyntaxError,
16
16
  evaluate_required_if,
17
+ is_certificate_ref,
17
18
  is_secret_ref,
19
+ parse_certificate_ref,
18
20
  parse_contract_ref,
19
21
  resolve_path,
20
22
  secret_name_from_ref,
@@ -170,6 +172,7 @@ def build_plan_from_profile(
170
172
  spec = profile.get("spec", {})
171
173
  secrets, secret_diags = load_profile_secrets(spec.get("secrets"), env_file)
172
174
  diagnostics.extend(secret_diags)
175
+ certificates = build_certificate_env_map(spec.get("certificates"))
173
176
 
174
177
  modules = spec.get("modules", [])
175
178
  if not isinstance(modules, list):
@@ -270,6 +273,8 @@ def build_plan_from_profile(
270
273
 
271
274
  # Validate secrets exist but leave "secrets.VAR" strings intact
272
275
  resolve_secret_refs(normalized_config, secrets, f"spec.modules[{i}].config", diagnostics)
276
+ # Same for certificate refs (issue #578): env names only, never material
277
+ resolve_certificate_refs(normalized_config, certificates, f"spec.modules[{i}].config", diagnostics)
273
278
 
274
279
  loaded = {
275
280
  "index": i,
@@ -310,6 +315,7 @@ def build_plan_from_profile(
310
315
  "version": inst["version"],
311
316
  "dependsOn": inst["dependsOn"],
312
317
  "config": inst["config"],
318
+ "security": deepcopy(inst["instance"].get("security") or {}),
313
319
  "consumes": resolve_consumed_contracts(inst, module_instances_by_id, resolved_contracts_by_module, diagnostics, secrets),
314
320
  "provides": resolved_contracts_by_module[inst["id"]],
315
321
  "implementation": inst["module"].get("spec", {}).get("implementation", {}),
@@ -324,7 +330,9 @@ def build_plan_from_profile(
324
330
  "environment": environment,
325
331
  "provenance": provenance,
326
332
  "runtime": spec.get("runtime", {}),
333
+ "security": deepcopy(spec.get("security") or {}),
327
334
  "secrets": secrets,
335
+ "certificates": certificates,
328
336
  "outputs": resolve_outputs(spec.get("outputs", {}), resolved_contracts_by_module, diagnostics),
329
337
  "modules": planned_modules,
330
338
  }
@@ -682,6 +690,67 @@ def resolve_consumed_contracts(
682
690
 
683
691
  return resolved
684
692
 
693
+ def build_certificate_env_map(spec_certificates: Any) -> dict[str, dict[str, str]]:
694
+ """
695
+ Map each certificate alias to its two runtime environment variable NAMES
696
+ (issue #578): alias -> {"cert": certEnv, "key": keyEnv}.
697
+
698
+ Only names ever flow through CDS -- certificate/key material is supplied
699
+ by the external secret/cert provider at runtime and is never read,
700
+ planned, or rendered here.
701
+ """
702
+ if not isinstance(spec_certificates, dict):
703
+ return {}
704
+ values = spec_certificates.get("values")
705
+ if not isinstance(values, dict):
706
+ return {}
707
+ certificates: dict[str, dict[str, str]] = {}
708
+ for alias, definition in values.items():
709
+ if not isinstance(definition, dict):
710
+ continue
711
+ cert_env = definition.get("certEnv")
712
+ key_env = definition.get("keyEnv")
713
+ if isinstance(cert_env, str) and cert_env and isinstance(key_env, str) and key_env:
714
+ certificates[alias] = {"cert": cert_env, "key": key_env}
715
+ return certificates
716
+
717
+
718
+ def resolve_certificate_refs(
719
+ obj: Any, certificates: dict[str, dict[str, str]], current_path: str, diagnostics: list[Diagnostic]
720
+ ) -> Any:
721
+ """
722
+ Validate that `certificates.<alias>.cert|.key` refs in planned config name
723
+ a declared alias, but leave the ref strings intact -- the renderer turns
724
+ them into ${VAR} runtime placeholders, exactly like secrets refs.
725
+ """
726
+ if isinstance(obj, dict):
727
+ return {
728
+ key: resolve_certificate_refs(value, certificates, f"{current_path}.{key}", diagnostics)
729
+ for key, value in obj.items()
730
+ }
731
+
732
+ if isinstance(obj, list):
733
+ return [
734
+ resolve_certificate_refs(value, certificates, f"{current_path}[{index}]", diagnostics)
735
+ for index, value in enumerate(obj)
736
+ ]
737
+
738
+ if isinstance(obj, str) and is_certificate_ref(obj):
739
+ parsed = parse_certificate_ref(obj)
740
+ alias = parsed[0] if parsed else None
741
+ if alias is None or alias not in certificates:
742
+ diagnostics.append(
743
+ Diagnostic(
744
+ level="error",
745
+ code="E129",
746
+ message=f'Certificate ref "{obj}" could not be resolved.',
747
+ path=current_path,
748
+ )
749
+ )
750
+
751
+ return obj # always return unchanged
752
+
753
+
685
754
  def resolve_secret_refs(obj: Any, secrets: dict[str, str], current_path: str, diagnostics: list[Diagnostic]) -> Any:
686
755
  """
687
756
  Validate that all secrets.* references in obj exist in the secrets dict.
cli/preflight.py CHANGED
@@ -47,7 +47,7 @@ def run_preflight(
47
47
  strict: bool = False,
48
48
  ) -> list[PreflightCheck]:
49
49
  checks: list[PreflightCheck] = []
50
- checks.extend(_check_runtime(plan.get("runtime", {})))
50
+ checks.extend(_check_runtime(plan.get("runtime", {}), plan))
51
51
  checks.extend(_check_environment(compose_yaml, env_file))
52
52
  checks.extend(_check_ports(compose_yaml))
53
53
  checks.extend(_check_images(plan, compose_yaml, strict=strict))
@@ -58,7 +58,7 @@ def preflight_passed(checks: list[PreflightCheck]) -> bool:
58
58
  return not any(check.status == "FAIL" for check in checks)
59
59
 
60
60
 
61
- def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
61
+ def _check_runtime(runtime: dict[str, Any], plan: dict[str, Any] | None = None) -> list[PreflightCheck]:
62
62
  runtime_type = runtime.get("type")
63
63
  if runtime_type != "docker-compose":
64
64
  return [
@@ -93,7 +93,7 @@ def _check_runtime(runtime: dict[str, Any]) -> list[PreflightCheck]:
93
93
  daemon_check, docker_info = _check_daemon()
94
94
  checks.append(daemon_check)
95
95
  if docker_info is not None:
96
- checks.extend(_check_host_hardening(docker_info))
96
+ checks.extend(_check_host_hardening(docker_info, production=infer_profile_class(plan or {}) == "prod"))
97
97
  return checks
98
98
 
99
99
 
@@ -127,7 +127,7 @@ def _check_daemon() -> tuple[PreflightCheck, dict[str, Any] | None]:
127
127
  return PreflightCheck("PASS", "runtime.daemon", "Docker daemon is reachable."), info
128
128
 
129
129
 
130
- def _check_host_hardening(info: dict[str, Any]) -> list[PreflightCheck]:
130
+ def _check_host_hardening(info: dict[str, Any], production: bool = True) -> list[PreflightCheck]:
131
131
  """
132
132
  Inspect host/daemon settings from `docker info` without privileged access.
133
133
 
@@ -144,10 +144,13 @@ def _check_host_hardening(info: dict[str, Any]) -> list[PreflightCheck]:
144
144
  rootless = "name=rootless" in options
145
145
  checks = [
146
146
  _host_check(
147
- rootless,
147
+ rootless or not production,
148
148
  "host.rootless",
149
- "Docker daemon runs in rootless mode.",
150
- "Docker daemon runs as root. Prefer rootless mode (HOST-001).",
149
+ "Docker daemon runs in rootless mode."
150
+ if rootless
151
+ else "Docker daemon is not rootless (recommended for production hosts, HOST-001).",
152
+ "Docker daemon runs as root: a container breakout yields host root. "
153
+ "Use rootless Docker for production hosts (HOST-001, see docs/rootless-docker.md).",
151
154
  ),
152
155
  _host_check(
153
156
  rootless or "name=userns" in options,