Coverage for src/keel/config.py: 100%
197 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-18 12:05 +0000
1"""Load + validate a keel ``project.yaml`` into a typed, immutable config.
3Pure and deterministic: parsing the same YAML always yields the same
4``ProjectConfig`` and the same :func:`config_hash`. The only I/O is reading the
5file in :func:`load_config`; everything else operates on plain data so it is
6trivially unit-testable.
7"""
9from __future__ import annotations
11import hashlib
12import json
13import os
14from dataclasses import dataclass, field
15from pathlib import Path
16from typing import Any
17from urllib.parse import urlsplit
19from . import jsonschema_min
20from . import yaml_helper as yaml
21from .capabilities import validate_names
23# Keep both names coming from `model` — the one module with no intra-package imports.
24# Taking DEFAULT_GATE_TIMEOUT_S from `gates` instead closes a config -> gates -> config
25# cycle (gates names config in its TYPE_CHECKING imports). SLOTS: source of truth for
26# the named slots; DEFAULT_GATE_TIMEOUT_S: shared with the gate planner and runner.
27from .model import DEFAULT_GATE_TIMEOUT_S, DEFAULT_JURY_TIMEOUT_S, SLOTS
29SCHEMA_PATH = Path(__file__).parent / "schema" / "project.schema.json"
31DEFAULT_EXTENSIONS_DIR = ".keel/extensions"
33#: Vendors a ``knobs.delegate_profiles`` entry may declare. ``cli`` drives a local
34#: coding-agent CLI (#659); ``openai-compatible`` reaches any OpenAI-shaped hosted API
35#: — OpenRouter, Groq, DeepSeek, Together, LiteLLM, vLLM — from config (#666).
36DELEGATE_PROFILE_VENDORS = ("cli", "openai-compatible")
38#: Vendors whose profile must name an executable.
39_COMMAND_VENDORS = ("cli",)
40#: Vendors whose profile must name an endpoint + the env var holding its key.
41_ENDPOINT_VENDORS = ("openai-compatible",)
43#: Hosts an ``openai-compatible`` endpoint may use without an explicit opt-in.
44LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1", "[::1]")
46#: Environment opt-in for a **non-loopback** endpoint. It lives in the environment and
47#: deliberately **not** in ``project.yaml``: the threat model here is an
48#: attacker-influenced config, so the switch that permits reaching a remote host must
49#: sit outside the surface an attacker would control. Ported from ai-jury's
50#: ``JURY_ALLOW_REMOTE_ENDPOINT`` (same reasoning, same default-closed posture).
51ALLOW_REMOTE_ENDPOINT_ENV = "KEEL_ALLOW_REMOTE_ENDPOINT"
53#: How a ``cli`` profile's prompt reaches the command. ``stdin`` stays the default
54#: (positional-arg passing hangs some CLIs); ``arg`` is the opt-in for CLIs whose usage
55#: makes the prompt a positional argument (e.g. ``cursor-agent``).
56DELEGATE_PROMPT_MODES = ("stdin", "arg")
57DEFAULT_PROMPT_MODE = "stdin"
59#: Flag a ``cli`` profile's command takes the model on. Near-universal across coding-agent
60#: CLIs (``cursor-agent``, ``gemini``, Aider all spell it ``--model``), but configurable
61#: because "arbitrary CLI" is the whole point and nothing guarantees the spelling.
62DEFAULT_MODEL_ARG = "--model"
64__all__ = ["SLOTS", "DEFAULT_EXTENSIONS_DIR", "DELEGATE_PROFILE_VENDORS",
65 "LOOPBACK_HOSTS", "ALLOW_REMOTE_ENDPOINT_ENV",
66 "DELEGATE_PROMPT_MODES", "DEFAULT_PROMPT_MODE", "DEFAULT_MODEL_ARG",
67 "Automation", "DelegateProfile",
68 "Knobs", "ProjectConfig", "ConfigError", "load_config", "parse_config",
69 "validate_data", "load_schema", "config_hash", "delegate_profiles_dict"]
72class ConfigError(ValueError):
73 """Raised when a project config fails schema validation."""
75 def __init__(self, source: str, errors: list[str]):
76 self.source = source
77 self.errors = list(errors)
78 joined = "\n - ".join(self.errors)
79 super().__init__(f"invalid keel config {source}:\n - {joined}")
82def load_schema() -> dict:
83 """Load the bundled JSON Schema for ``project.yaml``."""
84 return json.loads(SCHEMA_PATH.read_text(encoding="utf-8"))
87def validate_data(data: Any, schema: dict | None = None) -> list[str]:
88 """Return schema-validation errors for raw config data (empty == valid)."""
89 return jsonschema_min.validate(data, schema if schema is not None else load_schema())
92@dataclass(frozen=True)
93class DelegateProfile:
94 """A named generic-delegate vendor, referenced as ``--delegate <name>``.
96 Turns provider support into configuration: a ``cli`` profile names a local
97 coding-agent CLI (``command``) and how its prompt is delivered (``prompt_mode``),
98 so ``cursor-agent``/``gemini``/Aider/Goose become config entries rather than code
99 changes. ``command`` is operator-authored config with the same trust level as
100 ``build_gate_cmd`` — it is never taken from PR content or agent output.
101 """
103 vendor: str
104 command: str | None = None
105 #: Fixed flags the command always needs, e.g. ``["-p", "--force"]`` for
106 #: ``cursor-agent`` (print mode + non-interactive approval). ``command`` is one
107 #: executable, so without this an operator would have to smuggle flags into it as a
108 #: string keel would then treat as a filename.
109 args: tuple[str, ...] = ()
110 #: Flags for the **reviewer** role, when they must differ from ``args``. s7 asks a
111 #: reviewer for findings only, but ``args`` typically carries the implementer's
112 #: write-enabling flags (``--force`` approves edits non-interactively). Falls back to
113 #: ``args`` when unset — and keel cannot *enforce* read-only for an arbitrary CLI, so
114 #: this is the operator's lever, not a guarantee. See :meth:`role_args`.
115 review_args: tuple[str, ...] | None = None
116 prompt_mode: str = DEFAULT_PROMPT_MODE
117 model: str | None = None
118 #: How the effective model reaches the command: ``<model_arg> <model>``. Without it
119 #: the documented model precedence would be unimplementable for an arbitrary CLI —
120 #: attribution would record a model that was never actually selected.
121 model_arg: str = DEFAULT_MODEL_ARG
122 #: ``openai-compatible`` only: the OpenAI-shaped chat-completions URL. Validated
123 #: by :func:`endpoint_issues` — loopback by default, remote behind an env opt-in.
124 endpoint: str | None = None
125 #: ``openai-compatible`` only: the **name** of the env var holding the API key.
126 #: Never the key. Profile config is serialised into the command contract and
127 #: hashed into ``config_hash``, so a value here would be published.
128 api_key_env: str | None = None
130 def role_args(self, *, review: bool = False) -> tuple[str, ...]:
131 """Flags for this role: ``review_args`` for a reviewer when set, else ``args``."""
132 if review and self.review_args is not None:
133 return self.review_args
134 return self.args
137@dataclass(frozen=True)
138class Knobs:
139 """Per-project values consumed by the (otherwise neutral) backbone steps."""
141 build_gate_cmd: str
142 lint_cmd: str | None = None
143 implementer_agents: dict[str, str] = field(default_factory=dict)
144 #: Profile name -> generic delegate vendor config. Never shadows a built-in vendor
145 #: (``claude``/``codex``/``agy``/``ollama``/``*-api``); that is a validation error.
146 delegate_profiles: dict[str, DelegateProfile] = field(default_factory=dict)
147 tier3_globs: tuple[str, ...] = ()
148 ci_workflows: dict[str, str] = field(default_factory=dict)
149 docs_gate_paths: tuple[str, ...] = ()
150 docs_only_allowlist: tuple[str, ...] = ()
151 sot_doc: str | None = None
152 required_capabilities: tuple[str, ...] = ()
153 optional_capabilities: tuple[str, ...] = ()
154 evidence_gate_label: str = "keel:ship"
155 evidence_require_distinct_vendors: bool = False
156 #: Wall-clock seconds a command gate may run before it is killed. Raise this on a
157 #: slow host; a single slower gate can override it with ``timeout:`` frontmatter.
158 gate_timeout_s: int = DEFAULT_GATE_TIMEOUT_S
159 #: Wall-clock seconds the ``jury`` built-in may run. Separate from gate_timeout_s:
160 #: a cross-vendor panel and a test suite have unrelated runtimes.
161 jury_timeout_s: int = DEFAULT_JURY_TIMEOUT_S
164@dataclass(frozen=True)
165class Automation:
166 """Trusted unattended-run consent defaults."""
168 approved_scopes: tuple[str, ...] = ()
169 operator: str | None = None
172@dataclass(frozen=True)
173class ProjectConfig:
174 """A resolved, immutable keel project config."""
176 extends: str
177 core_version: str
178 base_branch: str
179 knobs: Knobs
180 owner: str | None = None
181 repo: str | None = None
182 platform: str | None = None
183 timezone: str | None = None
184 merge_window: str | None = None
185 merge_window_mode: str = "freeze"
186 consent_mode: str = "explicit"
187 gates: tuple[str, ...] = ()
188 extensions: dict[str, tuple[str, ...]] = field(default_factory=dict)
189 extensions_dir: str = DEFAULT_EXTENSIONS_DIR
190 policy_pack: dict[str, Any] = field(default_factory=dict)
191 automation: Automation = field(default_factory=Automation)
193 def slot(self, name: str) -> tuple[str, ...]:
194 """Extension files registered for a named slot (``()`` if none)."""
195 if name not in SLOTS:
196 raise KeyError(f"unknown slot {name!r}; valid slots: {', '.join(SLOTS)}")
197 return self.extensions.get(name, ())
200def _build(data: dict) -> ProjectConfig:
201 k = data["knobs"]
202 knobs = Knobs(
203 build_gate_cmd=k["build_gate_cmd"],
204 lint_cmd=k.get("lint_cmd"),
205 implementer_agents=dict(k.get("implementer_agents", {})),
206 delegate_profiles={
207 name: DelegateProfile(
208 vendor=profile["vendor"],
209 command=profile.get("command"),
210 args=tuple(profile.get("args", ())),
211 # An explicit null round-trips as "unset" — distinct from [], which
212 # means "the reviewer takes no flags at all".
213 review_args=(
214 tuple(profile["review_args"])
215 if profile.get("review_args") is not None
216 else None
217 ),
218 prompt_mode=profile.get("prompt_mode", DEFAULT_PROMPT_MODE),
219 model=profile.get("model"),
220 model_arg=profile.get("model_arg") or DEFAULT_MODEL_ARG,
221 endpoint=profile.get("endpoint"),
222 api_key_env=profile.get("api_key_env"),
223 )
224 for name, profile in k.get("delegate_profiles", {}).items()
225 },
226 tier3_globs=tuple(k.get("tier3_globs", [])),
227 ci_workflows=dict(k.get("ci_workflows", {})),
228 docs_gate_paths=tuple(k.get("docs_gate_paths", [])),
229 docs_only_allowlist=tuple(k.get("docs_only_allowlist", [])),
230 sot_doc=k.get("sot_doc"),
231 required_capabilities=tuple(k.get("required_capabilities", [])),
232 optional_capabilities=tuple(k.get("optional_capabilities", [])),
233 evidence_gate_label=k.get("evidence_gate_label", "keel:ship"),
234 evidence_require_distinct_vendors=bool(k.get("evidence_require_distinct_vendors", False)),
235 gate_timeout_s=int(k.get("gate_timeout_s", DEFAULT_GATE_TIMEOUT_S)),
236 jury_timeout_s=int(k.get("jury_timeout_s", DEFAULT_JURY_TIMEOUT_S)),
237 )
238 extensions = {slot: tuple(files) for slot, files in data.get("extensions", {}).items()}
239 automation_data = data.get("automation", {})
240 automation_scopes = tuple(dict.fromkeys(automation_data.get("approved_scopes", [])))
241 return ProjectConfig(
242 extends=data["extends"],
243 core_version=data["core_version"],
244 base_branch=data["base_branch"],
245 knobs=knobs,
246 owner=data.get("owner"),
247 repo=data.get("repo"),
248 platform=data.get("platform"),
249 timezone=data.get("timezone"),
250 merge_window=data.get("merge_window"),
251 merge_window_mode=data.get("merge_window_mode", "freeze"),
252 consent_mode=data.get("consent_mode", "explicit"),
253 gates=tuple(data.get("gates", [])),
254 extensions=extensions,
255 extensions_dir=data.get("extensions_dir", DEFAULT_EXTENSIONS_DIR),
256 policy_pack=json.loads(json.dumps(data.get("policy_pack", {}), sort_keys=True)),
257 automation=Automation(
258 approved_scopes=tuple(sorted(automation_scopes)),
259 operator=automation_data.get("operator"),
260 ),
261 )
264def parse_config(data: Any, *, source: str = "<dict>", schema: dict | None = None) -> ProjectConfig:
265 """Validate raw data and build a :class:`ProjectConfig` (raises on error)."""
266 if not isinstance(data, dict):
267 raise ConfigError(source, [f"$: expected an object (got {type(data).__name__})"])
268 errors = validate_data(data, schema)
269 if isinstance(data, dict) and isinstance(data.get("knobs"), dict):
270 knobs = data["knobs"]
271 errors.extend(validate_names(
272 tuple(knobs.get("required_capabilities", [])),
273 source=f"{source}: knobs.required_capabilities",
274 ))
275 errors.extend(validate_names(
276 tuple(knobs.get("optional_capabilities", [])),
277 source=f"{source}: knobs.optional_capabilities",
278 ))
279 errors.extend(_validate_delegate_profiles(
280 knobs.get("delegate_profiles", {}),
281 source=f"{source}: knobs.delegate_profiles",
282 ))
283 if isinstance(data, dict) and isinstance(data.get("policy_pack"), dict):
284 for path, names in _policy_capability_fields(data["policy_pack"]):
285 errors.extend(validate_names(tuple(names), source=f"{source}: {path}"))
286 if errors:
287 raise ConfigError(source, errors)
288 return _build(data)
291def load_config(path: str | Path) -> ProjectConfig:
292 """Read + validate a ``project.yaml`` from disk."""
293 path = Path(path)
294 data = yaml.load(path.read_text(encoding="utf-8"))
295 return parse_config(data, source=str(path))
298def config_hash(config: ProjectConfig) -> str:
299 """Stable SHA-256 over the canonicalised config (cache key / determinism)."""
300 payload = json.dumps(_canonical(config), sort_keys=True, separators=(",", ":"))
301 return hashlib.sha256(payload.encode("utf-8")).hexdigest()
304def _is_env_var_name(value: str) -> bool:
305 """Cheap shape check that ``api_key_env`` is a *name*, not a pasted secret."""
306 return bool(value) and not value[0].isdigit() and all(
307 ch.isalnum() or ch == "_" for ch in value
308 )
311def endpoint_issues(endpoint: Any, *, where: str, env=None) -> list[str]:
312 """Validate an ``openai-compatible`` endpoint URL. Empty list == acceptable.
314 A config-supplied URL is the one genuinely new risk in #666: every other keel
315 delegate talks to a hardcoded constant, which is why their SSRF story is trivial.
316 Letting config name the host makes ``project.yaml`` a request-forgery primitive
317 pointed wherever it says, including cloud-metadata addresses like
318 ``169.254.169.254``. Ported from ai-jury's ``_endpoint_issues`` rather than
319 reinvented — same decisions, same default-closed posture:
321 * a non-``http``/``https`` scheme is refused, which blocks ``file://``, ``ftp://``
322 and the other SSRF primitives;
323 * a malformed URL is a config error, not a stack trace out of ``keel validate``;
324 * a **non-loopback** host is refused unless the operator sets
325 :data:`ALLOW_REMOTE_ENDPOINT_ENV` in the environment. The opt-in is env-only on
326 purpose: an attacker who can edit config must not be able to grant it.
328 Plaintext ``http://`` to a permitted remote host is allowed but noted in the
329 message, since the prompt (and the diff in it) would cross the network in clear.
330 """
331 env = os.environ if env is None else env
332 if not isinstance(endpoint, str) or not endpoint.strip():
333 return [f"{where}: vendor 'openai-compatible' requires a non-empty 'endpoint'"]
334 try:
335 parsed = urlsplit(endpoint)
336 host = (parsed.hostname or "").lower()
337 except ValueError:
338 # urlsplit raises on e.g. "http://[::1" — by definition not a usable endpoint.
339 return [f"{where}: endpoint {endpoint!r} is not a valid URL"]
340 scheme = (parsed.scheme or "").lower()
341 if scheme not in ("http", "https"):
342 return [
343 f"{where}: endpoint scheme {parsed.scheme or '(none)'!r} is not allowed; "
344 "use http or https"
345 ]
346 if host in LOOPBACK_HOSTS:
347 return []
348 if not env.get(ALLOW_REMOTE_ENDPOINT_ENV):
349 return [
350 f"{where}: endpoint host {host or '(none)'!r} is not loopback; a remote "
351 "model server (including internal and cloud-metadata addresses) is refused "
352 f"by default. Set {ALLOW_REMOTE_ENDPOINT_ENV}=1 in the environment — not in "
353 "this file — to allow a trusted remote endpoint"
354 ]
355 return []
358def _validate_delegate_profiles(profiles: Any, *, source: str) -> list[str]:
359 """Return semantic errors for ``knobs.delegate_profiles`` (empty == valid).
361 The schema owns the *shape* (object of objects, `vendor` required, field types);
362 this owns the *meaning*: which vendors exist, what each vendor requires, and the
363 fail-closed rule that a profile may never shadow a built-in delegate vendor.
364 """
365 # Local import on purpose: ``agents`` imports this module for ``ProjectConfig``, so
366 # naming it at module scope would close a real config <-> agents cycle. The vendor
367 # vocabulary belongs next to the dispatch logic in ``agents``, so the import moves
368 # instead of the constant (same pattern as ``runtime._api_token_capability``).
369 from .agents import BUILTIN_DELEGATE_VENDORS
371 errors: list[str] = []
372 if not isinstance(profiles, dict):
373 return errors # the schema already reported the wrong shape
374 for name, profile in profiles.items():
375 where = f"{source}.{name}"
376 # A YAML mapping key is not necessarily a string: SafeLoader resolves an
377 # unquoted ``on:``/``2:``/``~:`` to bool/int/None, and the JSON schema validates
378 # property *values* only, never key types. So this has to be the first check —
379 # everything below assumes ``str`` methods, and reaching them with a bool raised
380 # an uncaught AttributeError out of ``keel validate``.
381 if not isinstance(name, str):
382 errors.append(
383 f"{source}: delegate profile name {name!r} is {type(name).__name__}, not a "
384 "string — quote the key (YAML reads a bare on/off/yes/no/true/false as a "
385 "boolean and a bare number as an int)"
386 )
387 continue
388 if name in BUILTIN_DELEGATE_VENDORS:
389 errors.append(
390 f"{where}: profile name {name!r} shadows a built-in delegate vendor; "
391 f"built-ins always win and may not be redefined "
392 f"({', '.join(BUILTIN_DELEGATE_VENDORS)}) — rename the profile"
393 )
394 elif name in DELEGATE_PROFILE_VENDORS:
395 # `delegate_profile` exists to say *which* CLI ran. A profile named after its
396 # own vendor makes every attribution field read "cli", which is exactly the
397 # ambiguity the field was added to remove.
398 errors.append(
399 f"{where}: profile name {name!r} is a delegate vendor name and would make "
400 "attribution ambiguous — agent:cli, system 'cli' and delegate_profile "
401 "'cli' would all say the same nothing. Name it after the CLI, e.g. "
402 "'cursor'"
403 )
404 # A name that can never be selected is a config error, not a silent dead entry:
405 # ``--delegate`` is split on the first colon, so a name containing one resolves
406 # to a different (missing) profile, and an empty name reads as no delegate at all.
407 if not name.strip():
408 errors.append(
409 f"{source}: a delegate profile name may not be empty or blank — "
410 "an empty --delegate reads as no delegate at all"
411 )
412 elif ":" in name:
413 errors.append(
414 f"{where}: profile name {name!r} may not contain ':' — --delegate splits "
415 "on the first colon to separate the profile from a per-run model, so this "
416 "name could never be selected"
417 )
418 if not isinstance(profile, dict) or "vendor" not in profile:
419 continue # shape + required-field errors are the schema's job
420 vendor = profile["vendor"]
421 if vendor not in DELEGATE_PROFILE_VENDORS:
422 errors.append(
423 f"{where}: unknown delegate vendor {vendor!r}; "
424 f"valid: {', '.join(DELEGATE_PROFILE_VENDORS)}"
425 )
426 elif vendor in _COMMAND_VENDORS and not profile.get("command"):
427 errors.append(
428 f"{where}: vendor {vendor!r} requires a non-empty 'command' — the "
429 "executable keel runs (e.g. cursor-agent)"
430 )
431 elif vendor in _ENDPOINT_VENDORS:
432 errors.extend(endpoint_issues(profile.get("endpoint"), where=where))
433 key_env = profile.get("api_key_env")
434 if not key_env or not isinstance(key_env, str) or not key_env.strip():
435 errors.append(
436 f"{where}: vendor {vendor!r} requires 'api_key_env' — the *name* of "
437 "the environment variable holding the key. Never the key itself: "
438 "profile config is serialised into the command contract and hashed "
439 "into config_hash, so a value here would be published"
440 )
441 elif not _is_env_var_name(key_env):
442 errors.append(
443 f"{where}: api_key_env {key_env!r} is not a valid environment "
444 "variable name (letters, digits, underscore; not starting with a "
445 "digit) — this field takes a name, not a key"
446 )
447 # A field that does not apply to this vendor is a config error, not a
448 # silently-ignored key: an operator who sets `endpoint` on a `cli` profile has
449 # a mistaken model of what will run, and the schema cannot catch it because
450 # both fields are legal *somewhere*.
451 for field_name, owners in (("command", _COMMAND_VENDORS),
452 ("endpoint", _ENDPOINT_VENDORS),
453 ("api_key_env", _ENDPOINT_VENDORS)):
454 if profile.get(field_name) and vendor in DELEGATE_PROFILE_VENDORS \
455 and vendor not in owners:
456 errors.append(
457 f"{where}: {field_name!r} does not apply to vendor {vendor!r} "
458 f"(only {', '.join(owners)}) — it would be silently ignored"
459 )
460 prompt_mode = profile.get("prompt_mode", DEFAULT_PROMPT_MODE)
461 if prompt_mode not in DELEGATE_PROMPT_MODES:
462 errors.append(
463 f"{where}: invalid prompt_mode {prompt_mode!r}; "
464 f"valid: {', '.join(DELEGATE_PROMPT_MODES)}"
465 )
466 return errors
469def _policy_capability_fields(value: Any, path: str = "policy_pack") -> list[tuple[str, list]]:
470 fields: list[tuple[str, list]] = []
471 if isinstance(value, dict):
472 for key, child in value.items():
473 child_path = f"{path}.{key}"
474 if (
475 key in {"required_capabilities", "optional_capabilities"}
476 and isinstance(child, list)
477 ):
478 fields.append((child_path, child))
479 else:
480 fields.extend(_policy_capability_fields(child, child_path))
481 elif isinstance(value, list):
482 for i, child in enumerate(value):
483 fields.extend(_policy_capability_fields(child, f"{path}[{i}]"))
484 return fields
487def delegate_profiles_dict(config: ProjectConfig) -> dict:
488 """``{"delegate_profiles": {...}}``, or ``{}`` when none are configured.
490 Shared by :func:`_canonical` and ``contracts.project_as_dict`` so the hashed form
491 and the published contract cannot drift apart. Empty means **absent**, not ``{}``:
492 an added optional field must not change ``config_hash`` for projects that never
493 used it.
494 """
495 profiles = config.knobs.delegate_profiles
496 if not profiles:
497 return {}
498 return {
499 "delegate_profiles": {
500 name: {
501 "vendor": profile.vendor,
502 "command": profile.command,
503 "args": list(profile.args),
504 "review_args": (
505 list(profile.review_args) if profile.review_args is not None else None
506 ),
507 "prompt_mode": profile.prompt_mode,
508 "model": profile.model,
509 "model_arg": profile.model_arg,
510 "endpoint": profile.endpoint,
511 "api_key_env": profile.api_key_env,
512 }
513 for name, profile in sorted(profiles.items())
514 }
515 }
518def _canonical(config: ProjectConfig) -> dict:
519 return {
520 "extends": config.extends,
521 "core_version": config.core_version,
522 "base_branch": config.base_branch,
523 "owner": config.owner,
524 "repo": config.repo,
525 "platform": config.platform,
526 "timezone": config.timezone,
527 "merge_window": config.merge_window,
528 "merge_window_mode": config.merge_window_mode,
529 "consent_mode": config.consent_mode,
530 "gates": list(config.gates),
531 "extensions_dir": config.extensions_dir,
532 "extensions": {k: list(v) for k, v in sorted(config.extensions.items())},
533 "policy_pack": config.policy_pack,
534 "automation": {
535 "approved_scopes": list(config.automation.approved_scopes),
536 "operator": config.automation.operator,
537 },
538 "knobs": {
539 "build_gate_cmd": config.knobs.build_gate_cmd,
540 "lint_cmd": config.knobs.lint_cmd,
541 "implementer_agents": dict(sorted(config.knobs.implementer_agents.items())),
542 # Omitted entirely when empty: emitting "delegate_profiles": {} would rotate
543 # config_hash for every project that has never configured one, which is the
544 # normal treatment for an added optional field.
545 **delegate_profiles_dict(config),
546 "tier3_globs": list(config.knobs.tier3_globs),
547 "ci_workflows": dict(sorted(config.knobs.ci_workflows.items())),
548 "docs_gate_paths": list(config.knobs.docs_gate_paths),
549 "docs_only_allowlist": list(config.knobs.docs_only_allowlist),
550 "sot_doc": config.knobs.sot_doc,
551 "required_capabilities": list(config.knobs.required_capabilities),
552 "optional_capabilities": list(config.knobs.optional_capabilities),
553 "evidence_gate_label": config.knobs.evidence_gate_label,
554 "evidence_require_distinct_vendors": config.knobs.evidence_require_distinct_vendors,
555 "gate_timeout_s": config.knobs.gate_timeout_s,
556 "jury_timeout_s": config.knobs.jury_timeout_s,
557 },
558 }