rainskills 0.1.0-rc.0

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.
Files changed (133) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +374 -0
  3. package/bin/rainskills.js +71 -0
  4. package/install.sh +1919 -0
  5. package/package.json +55 -0
  6. package/rainbond-app-assistant/SKILL.md +1319 -0
  7. package/rainbond-app-assistant/evals/01-linked-topology-missing.expected.yaml +21 -0
  8. package/rainbond-app-assistant/evals/01-linked-topology-missing.response.md +53 -0
  9. package/rainbond-app-assistant/evals/02-linked-topology-building.expected.yaml +19 -0
  10. package/rainbond-app-assistant/evals/02-linked-topology-building.response.md +53 -0
  11. package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.expected.yaml +19 -0
  12. package/rainbond-app-assistant/evals/03-runtime-healthy-delivery-unknown.response.md +53 -0
  13. package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.expected.yaml +21 -0
  14. package/rainbond-app-assistant/evals/04-delivered-stop-without-promotion.response.md +58 -0
  15. package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.expected.yaml +21 -0
  16. package/rainbond-app-assistant/evals/05-delivered-explicit-dev-to-test.response.md +80 -0
  17. package/rainbond-app-assistant/evals/06-manual-validation-stop.expected.yaml +16 -0
  18. package/rainbond-app-assistant/evals/06-manual-validation-stop.response.md +58 -0
  19. package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.expected.yaml +15 -0
  20. package/rainbond-app-assistant/evals/07-cluster-capacity-blocked.response.md +53 -0
  21. package/rainbond-app-assistant/evals/08-code-build-handoff-stop.expected.yaml +21 -0
  22. package/rainbond-app-assistant/evals/08-code-build-handoff-stop.response.md +56 -0
  23. package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.expected.yaml +22 -0
  24. package/rainbond-app-assistant/evals/09-source-backed-failure-no-fallback.response.md +56 -0
  25. package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.expected.yaml +21 -0
  26. package/rainbond-app-assistant/evals/10-identity-ambiguous-stop.response.md +45 -0
  27. package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.expected.yaml +28 -0
  28. package/rainbond-app-assistant/evals/11-delivered-with-proxy-and-local-binding.response.md +64 -0
  29. package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.expected.yaml +24 -0
  30. package/rainbond-app-assistant/evals/12-complex-suite-needs-descriptor-stop.response.md +45 -0
  31. package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.expected.yaml +28 -0
  32. package/rainbond-app-assistant/evals/13-explicit-git-url-locks-source.response.md +53 -0
  33. package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.expected.yaml +22 -0
  34. package/rainbond-app-assistant/evals/14-strategy-switch-cleans-residue.response.md +53 -0
  35. package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.expected.yaml +23 -0
  36. package/rainbond-app-assistant/evals/15-turn-discipline-reuse-alias.response.md +53 -0
  37. package/rainbond-app-assistant/schemas/app-assistant-result.schema.yaml +279 -0
  38. package/rainbond-app-assistant/scripts/run_app_assistant_evals.py +80 -0
  39. package/rainbond-app-assistant/scripts/validate_app_assistant_output.py +935 -0
  40. package/rainbond-app-version-assistant/SKILL.md +444 -0
  41. package/rainbond-delivery-verifier/SKILL.md +372 -0
  42. package/rainbond-delivery-verifier/evals/01-delivered-verified.expected.yaml +19 -0
  43. package/rainbond-delivery-verifier/evals/01-delivered-verified.response.md +31 -0
  44. package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.expected.yaml +19 -0
  45. package/rainbond-delivery-verifier/evals/02-delivered-manual-validation.response.md +31 -0
  46. package/rainbond-delivery-verifier/evals/03-partially-delivered-building.expected.yaml +20 -0
  47. package/rainbond-delivery-verifier/evals/03-partially-delivered-building.response.md +31 -0
  48. package/rainbond-delivery-verifier/evals/04-blocked-capacity.expected.yaml +18 -0
  49. package/rainbond-delivery-verifier/evals/04-blocked-capacity.response.md +31 -0
  50. package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.expected.yaml +20 -0
  51. package/rainbond-delivery-verifier/evals/05-blocked-no-usable-url.response.md +31 -0
  52. package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.expected.yaml +19 -0
  53. package/rainbond-delivery-verifier/evals/06-blocked-runtime-unhealthy.response.md +31 -0
  54. package/rainbond-delivery-verifier/schemas/delivery-verification-result.schema.yaml +84 -0
  55. package/rainbond-delivery-verifier/scripts/run_delivery_verifier_evals.py +78 -0
  56. package/rainbond-delivery-verifier/scripts/validate_delivery_verifier_output.py +728 -0
  57. package/rainbond-env-sync/SKILL.md +496 -0
  58. package/rainbond-fullstack-bootstrap/SKILL.md +262 -0
  59. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.expected.yaml +27 -0
  60. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.prompt.md +1 -0
  61. package/rainbond-fullstack-bootstrap/evals/01-reuse-only.response.md +43 -0
  62. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.expected.yaml +27 -0
  63. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.prompt.md +1 -0
  64. package/rainbond-fullstack-bootstrap/evals/02-deferred-dependency.response.md +44 -0
  65. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.expected.yaml +26 -0
  66. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.prompt.md +1 -0
  67. package/rainbond-fullstack-bootstrap/evals/03-capacity-blocked.response.md +43 -0
  68. package/rainbond-fullstack-bootstrap/evals/04-template-skip.expected.yaml +31 -0
  69. package/rainbond-fullstack-bootstrap/evals/04-template-skip.prompt.md +1 -0
  70. package/rainbond-fullstack-bootstrap/evals/04-template-skip.response.md +43 -0
  71. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.expected.yaml +33 -0
  72. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.prompt.md +1 -0
  73. package/rainbond-fullstack-bootstrap/evals/05-source-ambiguity-stop.response.md +43 -0
  74. package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.expected.yaml +23 -0
  75. package/rainbond-fullstack-bootstrap/evals/06-external-artifact-unreachable.response.md +39 -0
  76. package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.expected.yaml +29 -0
  77. package/rainbond-fullstack-bootstrap/evals/07-provider-connection-contract.response.md +43 -0
  78. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.expected.yaml +30 -0
  79. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.prompt.md +1 -0
  80. package/rainbond-fullstack-bootstrap/evals/08-explicit-dependency-after-manual-components.response.md +43 -0
  81. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.expected.yaml +32 -0
  82. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.prompt.md +1 -0
  83. package/rainbond-fullstack-bootstrap/evals/09-inferred-provider-consumer-gate.response.md +41 -0
  84. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.expected.yaml +34 -0
  85. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.prompt.md +1 -0
  86. package/rainbond-fullstack-bootstrap/evals/10-compose-service-name-not-hostname.response.md +43 -0
  87. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.expected.yaml +46 -0
  88. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.prompt.md +1 -0
  89. package/rainbond-fullstack-bootstrap/evals/11-reverse-proxy-not-dropped.response.md +48 -0
  90. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.expected.yaml +43 -0
  91. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.prompt.md +1 -0
  92. package/rainbond-fullstack-bootstrap/evals/12-optional-services-disclose-not-deploy.response.md +63 -0
  93. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.expected.yaml +47 -0
  94. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.prompt.md +1 -0
  95. package/rainbond-fullstack-bootstrap/evals/13-env-reference-dependency-completeness.response.md +60 -0
  96. package/rainbond-fullstack-bootstrap/modules/10-context-loading.md +157 -0
  97. package/rainbond-fullstack-bootstrap/modules/20-scope-and-boundaries.md +105 -0
  98. package/rainbond-fullstack-bootstrap/modules/30-creation-rules.md +337 -0
  99. package/rainbond-fullstack-bootstrap/modules/40-source-and-package-rules.md +245 -0
  100. package/rainbond-fullstack-bootstrap/modules/50-workflow-and-convergence.md +198 -0
  101. package/rainbond-fullstack-bootstrap/modules/60-verification-and-handoffs.md +143 -0
  102. package/rainbond-fullstack-bootstrap/modules/70-output-contract.md +152 -0
  103. package/rainbond-fullstack-bootstrap/references/manifest-v1-reference.md +150 -0
  104. package/rainbond-fullstack-bootstrap/references/quick-reference.md +101 -0
  105. package/rainbond-fullstack-bootstrap/references/source-build-parameter-guide.md +264 -0
  106. package/rainbond-fullstack-bootstrap/references/sql-init-recipe.md +83 -0
  107. package/rainbond-fullstack-bootstrap/schemas/bootstrap-result.schema.yaml +217 -0
  108. package/rainbond-fullstack-bootstrap/scripts/run_bootstrap_evals.py +74 -0
  109. package/rainbond-fullstack-bootstrap/scripts/validate_bootstrap_output.py +615 -0
  110. package/rainbond-fullstack-troubleshooter/SKILL.md +870 -0
  111. package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.expected.yaml +29 -0
  112. package/rainbond-fullstack-troubleshooter/evals/01-source-build-failed.response.md +55 -0
  113. package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.expected.yaml +20 -0
  114. package/rainbond-fullstack-troubleshooter/evals/02-source-build-still-running.response.md +53 -0
  115. package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.expected.yaml +21 -0
  116. package/rainbond-fullstack-troubleshooter/evals/03-dependency-missing.response.md +59 -0
  117. package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.expected.yaml +19 -0
  118. package/rainbond-fullstack-troubleshooter/evals/04-wrong-connection-values.response.md +58 -0
  119. package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.expected.yaml +18 -0
  120. package/rainbond-fullstack-troubleshooter/evals/05-env-naming-incompatibility.response.md +59 -0
  121. package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.expected.yaml +21 -0
  122. package/rainbond-fullstack-troubleshooter/evals/06-frontend-access-path-issue.response.md +56 -0
  123. package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.expected.yaml +20 -0
  124. package/rainbond-fullstack-troubleshooter/evals/07-cluster-capacity-blocked.response.md +57 -0
  125. package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.expected.yaml +21 -0
  126. package/rainbond-fullstack-troubleshooter/evals/08-code-or-build-handoff-needed.response.md +55 -0
  127. package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.expected.yaml +28 -0
  128. package/rainbond-fullstack-troubleshooter/evals/09-external-artifact-unreachable.response.md +53 -0
  129. package/rainbond-fullstack-troubleshooter/schemas/troubleshoot-result.schema.yaml +217 -0
  130. package/rainbond-fullstack-troubleshooter/scripts/run_troubleshooter_evals.py +80 -0
  131. package/rainbond-fullstack-troubleshooter/scripts/validate_troubleshoot_output.py +789 -0
  132. package/rainbond-project-init/SKILL.md +1001 -0
  133. package/rainbond-template-installer/SKILL.md +410 -0
@@ -0,0 +1,935 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Deterministic validator for app-assistant orchestration-level structured output."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import argparse
8
+ import json
9
+ import re
10
+ import sys
11
+ from pathlib import Path
12
+ from typing import Any
13
+
14
+ import yaml
15
+
16
+
17
+ REQUIRED_SECTIONS = [
18
+ "### Project State",
19
+ "### Actions Performed",
20
+ "### Current Health",
21
+ "### Blocking Issue",
22
+ "### Next Step",
23
+ "### Structured Output",
24
+ ]
25
+
26
+ SECRET_KEYWORDS = (
27
+ "password",
28
+ "secret",
29
+ "token",
30
+ "api_key",
31
+ "apikey",
32
+ "private_key",
33
+ "privatekey",
34
+ "certificate",
35
+ "cert",
36
+ "authorization",
37
+ "cookie",
38
+ )
39
+
40
+ MASKED_VALUES = {"***", "[masked]", "<masked>", "redacted", "<redacted>"}
41
+
42
+ FORBIDDEN_FALLBACK_PATTERNS = (
43
+ r"\bfallback(?:ed)?\s+to\s+(?:package|image|template)\b",
44
+ r"\bswitched\s+to\s+(?:package|image|template)\b",
45
+ r"\bdefault(?:ed)?\s+to\s+(?:package|image|template)\b",
46
+ r"\bused\s+(?:package|image|template)\s+fallback\b",
47
+ )
48
+
49
+ FORBIDDEN_CODE_HANDOFF_ACTION_PATTERNS = (
50
+ r"\bgo\s+test\b",
51
+ r"\bgo\s+build\b",
52
+ r"\bgo\s+vet\b",
53
+ r"\bnpm\s+(?:test|run|install)\b",
54
+ r"\byarn\s+(?:test|build|install)\b",
55
+ r"\bpnpm\s+(?:test|build|install)\b",
56
+ r"\b(?:ran|run|executed|started|used)\s+docker\s+(?:build|buildx|push|tag|login)\b",
57
+ r"\bopen\s+-a\s+orbstack\b",
58
+ r"\b(?:started|launched|opened)\s+orbstack\b",
59
+ r"\bgit\s+(?:commit|push)\b",
60
+ r"\bcommitted\b",
61
+ r"\bpushed\b",
62
+ r"\bmodified\s+source\b",
63
+ r"\bedited\s+source\b",
64
+ r"\bcode edit(?:s)?\b",
65
+ r"\blocal test(?:s)?\b",
66
+ )
67
+
68
+
69
+ # Canonical next_action vocabulary.
70
+ #
71
+ # 修改需同步: this list is the validator-side mirror of the "Canonical next_action
72
+ # vocabulary" table in SKILL.md (### Structured contract mode). Editing one without
73
+ # the other breaks the contract. Fixed phrases must match verbatim (after whitespace
74
+ # normalization); template phrases match by their leading + trailing literal segments
75
+ # (the text before the first <slot> and after the last <slot>), so the <...> slot can
76
+ # be filled with any value or, where the table allows, omitted.
77
+ CANONICAL_NEXT_ACTIONS = (
78
+ "stop",
79
+ "run bootstrap",
80
+ "run troubleshooter",
81
+ "run troubleshooter on the same source path",
82
+ "run delivery verifier",
83
+ "fix cluster capacity first",
84
+ "handoff to code/build agent",
85
+ "stop and validate URL manually",
86
+ "stop and ask the user to choose the team/app identity",
87
+ "stop and ask the user to provide a descriptor or template",
88
+ "build the linked source app on the user-provided GitHub URL",
89
+ "configure ports and envs on the known service_alias from the create return",
90
+ )
91
+
92
+ # Template phrases keyed by their literal prefix (before the first <slot>) and
93
+ # literal suffix (after the last <slot>). A candidate matches when it starts with the
94
+ # prefix and ends with the suffix and is at least as long as prefix+suffix combined.
95
+ CANONICAL_NEXT_ACTION_TEMPLATES = (
96
+ {
97
+ "template": "stop after reporting testing app verification for <app>",
98
+ "prefix": "stop after reporting testing app verification for ",
99
+ "suffix": "",
100
+ },
101
+ {
102
+ "template": "delete the abandoned half-installed template app <app> before building the source path",
103
+ "prefix": "delete the abandoned half-installed template app",
104
+ "suffix": "before building the source path",
105
+ },
106
+ )
107
+
108
+
109
+ class ValidationFailure(Exception):
110
+ """Raised when validation cannot continue."""
111
+
112
+
113
+ def main() -> int:
114
+ parser = build_parser()
115
+ args = parser.parse_args()
116
+
117
+ response_path = args.response.resolve()
118
+ schema_path = args.schema.resolve()
119
+ expected_path = args.expected.resolve() if args.expected else None
120
+
121
+ errors = validate_response_file(
122
+ response_path=response_path,
123
+ schema_path=schema_path,
124
+ expected_path=expected_path,
125
+ )
126
+
127
+ if errors:
128
+ print(f"FAIL {response_path}")
129
+ for error in errors:
130
+ print(f" - {error}")
131
+ return 1
132
+
133
+ print(f"PASS {response_path}")
134
+ return 0
135
+
136
+
137
+ def build_parser() -> argparse.ArgumentParser:
138
+ parser = argparse.ArgumentParser(
139
+ description="Validate an app-assistant markdown reply against AppAssistantResult."
140
+ )
141
+ parser.add_argument("response", type=Path, help="Path to the markdown response file.")
142
+ parser.add_argument(
143
+ "--schema",
144
+ type=Path,
145
+ default=Path(__file__).resolve().parents[1] / "schemas" / "app-assistant-result.schema.yaml",
146
+ help="Path to app-assistant-result.schema.yaml.",
147
+ )
148
+ parser.add_argument(
149
+ "--expected",
150
+ type=Path,
151
+ default=None,
152
+ help="Optional fixture-aware assertion file.",
153
+ )
154
+ return parser
155
+
156
+
157
+ def validate_response_file(
158
+ response_path: Path,
159
+ schema_path: Path,
160
+ expected_path: Path | None = None,
161
+ ) -> list[str]:
162
+ response_text = response_path.read_text(encoding="utf-8")
163
+ schema = load_yaml(schema_path)
164
+ expected = load_yaml(expected_path) if expected_path else None
165
+
166
+ errors: list[str] = []
167
+
168
+ try:
169
+ sections = parse_required_sections(response_text)
170
+ except ValidationFailure as exc:
171
+ return [str(exc)]
172
+
173
+ try:
174
+ structured_yaml = extract_structured_yaml_block(sections["### Structured Output"])
175
+ except ValidationFailure as exc:
176
+ return [str(exc)]
177
+
178
+ try:
179
+ payload = yaml.safe_load(structured_yaml)
180
+ except yaml.YAMLError as exc:
181
+ return [f"structured YAML did not parse: {exc}"]
182
+
183
+ if payload is None:
184
+ return ["structured YAML parsed to null; expected an AppAssistantResult object"]
185
+
186
+ errors.extend(check_for_secret_leaks(response_text, structured_yaml, payload))
187
+ errors.extend(validate_schema(payload, schema))
188
+
189
+ if not errors:
190
+ errors.extend(validate_cross_field_rules(sections, payload))
191
+ errors.extend(validate_prose_consistency(sections, payload))
192
+
193
+ if expected_path and not errors:
194
+ errors.extend(validate_expected_fixture(sections, payload, expected or {}))
195
+
196
+ return errors
197
+
198
+
199
+ def load_yaml(path: Path | None) -> Any:
200
+ if path is None:
201
+ return None
202
+ with path.open("r", encoding="utf-8") as handle:
203
+ return yaml.safe_load(handle)
204
+
205
+
206
+ def parse_required_sections(response_text: str) -> dict[str, str]:
207
+ heading_matches = list(re.finditer(r"(?m)^### [^\n]+$", response_text))
208
+ headings = [match.group(0).strip() for match in heading_matches]
209
+
210
+ if headings != REQUIRED_SECTIONS:
211
+ raise ValidationFailure(
212
+ "reply headings must be exactly and only: "
213
+ + ", ".join(REQUIRED_SECTIONS)
214
+ + f"; got: {headings}"
215
+ )
216
+
217
+ sections: dict[str, str] = {}
218
+ for index, match in enumerate(heading_matches):
219
+ start = match.end()
220
+ end = heading_matches[index + 1].start() if index + 1 < len(heading_matches) else len(response_text)
221
+ sections[match.group(0).strip()] = response_text[start:end].strip()
222
+
223
+ return sections
224
+
225
+
226
+ def extract_structured_yaml_block(structured_section: str) -> str:
227
+ matches = list(re.finditer(r"(?s)```yaml\s*\n(.*?)\n```", structured_section))
228
+ if len(matches) != 1:
229
+ raise ValidationFailure("### Structured Output must contain exactly one fenced ```yaml block")
230
+
231
+ if re.sub(r"(?s)```yaml\s*\n.*?\n```", "", structured_section).strip():
232
+ raise ValidationFailure("### Structured Output must contain only the fenced yaml block")
233
+
234
+ return matches[0].group(1)
235
+
236
+
237
+ def validate_schema(instance: Any, schema: dict[str, Any]) -> list[str]:
238
+ errors: list[str] = []
239
+ _validate_schema_node(instance, schema, "$", schema, errors)
240
+ return errors
241
+
242
+
243
+ def _validate_schema_node(
244
+ instance: Any,
245
+ schema: dict[str, Any],
246
+ path: str,
247
+ root_schema: dict[str, Any],
248
+ errors: list[str],
249
+ ) -> None:
250
+ if "$ref" in schema:
251
+ resolved = resolve_ref(schema["$ref"], root_schema)
252
+ _validate_schema_node(instance, resolved, path, root_schema, errors)
253
+ return
254
+
255
+ if "anyOf" in schema:
256
+ option_errors: list[list[str]] = []
257
+ for option in schema["anyOf"]:
258
+ nested_errors: list[str] = []
259
+ _validate_schema_node(instance, option, path, root_schema, nested_errors)
260
+ if not nested_errors:
261
+ break
262
+ option_errors.append(nested_errors)
263
+ else:
264
+ joined = "; ".join(" / ".join(option) for option in option_errors)
265
+ errors.append(f"{path}: did not match any allowed schema option ({joined})")
266
+ return
267
+
268
+ if "enum" in schema and instance not in schema["enum"]:
269
+ errors.append(f"{path}: expected one of {schema['enum']}, got {instance!r}")
270
+ return
271
+
272
+ if "const" in schema and instance != schema["const"]:
273
+ errors.append(f"{path}: expected constant {schema['const']!r}, got {instance!r}")
274
+ return
275
+
276
+ if "type" in schema and not matches_type(instance, schema["type"]):
277
+ errors.append(f"{path}: expected type {schema['type']!r}, got {type_name(instance)}")
278
+ return
279
+
280
+ if isinstance(instance, str) and "minLength" in schema and len(instance) < schema["minLength"]:
281
+ errors.append(f"{path}: expected string length >= {schema['minLength']}, got {len(instance)}")
282
+
283
+ if isinstance(instance, list) and "minItems" in schema and len(instance) < schema["minItems"]:
284
+ errors.append(f"{path}: expected at least {schema['minItems']} item(s), got {len(instance)}")
285
+
286
+ schema_type = normalized_type(schema.get("type"), instance)
287
+ if schema_type == "object":
288
+ validate_object(instance, schema, path, root_schema, errors)
289
+ elif schema_type == "array":
290
+ validate_array(instance, schema, path, root_schema, errors)
291
+
292
+
293
+ def resolve_ref(ref: str, root_schema: dict[str, Any]) -> dict[str, Any]:
294
+ if not ref.startswith("#/"):
295
+ raise ValidationFailure(f"unsupported schema ref: {ref}")
296
+
297
+ node: Any = root_schema
298
+ for part in ref[2:].split("/"):
299
+ node = node[part]
300
+ if not isinstance(node, dict):
301
+ raise ValidationFailure(f"schema ref {ref} did not resolve to an object node")
302
+ return node
303
+
304
+
305
+ def matches_type(instance: Any, expected_type: str | list[str]) -> bool:
306
+ if isinstance(expected_type, list):
307
+ return any(matches_type(instance, option) for option in expected_type)
308
+
309
+ if expected_type == "object":
310
+ return isinstance(instance, dict)
311
+ if expected_type == "array":
312
+ return isinstance(instance, list)
313
+ if expected_type == "string":
314
+ return isinstance(instance, str)
315
+ if expected_type == "null":
316
+ return instance is None
317
+ if expected_type == "boolean":
318
+ return isinstance(instance, bool)
319
+ if expected_type == "integer":
320
+ return isinstance(instance, int) and not isinstance(instance, bool)
321
+ if expected_type == "number":
322
+ return isinstance(instance, (int, float)) and not isinstance(instance, bool)
323
+ return False
324
+
325
+
326
+ def type_name(instance: Any) -> str:
327
+ if instance is None:
328
+ return "null"
329
+ if isinstance(instance, bool):
330
+ return "boolean"
331
+ if isinstance(instance, dict):
332
+ return "object"
333
+ if isinstance(instance, list):
334
+ return "array"
335
+ if isinstance(instance, str):
336
+ return "string"
337
+ if isinstance(instance, int):
338
+ return "integer"
339
+ if isinstance(instance, float):
340
+ return "number"
341
+ return type(instance).__name__
342
+
343
+
344
+ def normalized_type(expected_type: Any, instance: Any) -> str | None:
345
+ if isinstance(expected_type, str):
346
+ return expected_type
347
+ if isinstance(expected_type, list):
348
+ for option in expected_type:
349
+ if matches_type(instance, option):
350
+ return option
351
+ if isinstance(instance, dict):
352
+ return "object"
353
+ if isinstance(instance, list):
354
+ return "array"
355
+ return None
356
+
357
+
358
+ def validate_object(
359
+ instance: Any,
360
+ schema: dict[str, Any],
361
+ path: str,
362
+ root_schema: dict[str, Any],
363
+ errors: list[str],
364
+ ) -> None:
365
+ if not isinstance(instance, dict):
366
+ return
367
+
368
+ required = schema.get("required", [])
369
+ for key in required:
370
+ if key not in instance:
371
+ errors.append(f"{path}: missing required property {key!r}")
372
+
373
+ properties = schema.get("properties", {})
374
+ for key, subschema in properties.items():
375
+ if key in instance:
376
+ _validate_schema_node(instance[key], subschema, f"{path}.{key}", root_schema, errors)
377
+
378
+ allowed = set(properties.keys())
379
+ extras = [key for key in instance.keys() if key not in allowed]
380
+ additional = schema.get("additionalProperties", True)
381
+
382
+ if additional is False and extras:
383
+ errors.append(f"{path}: unexpected properties {extras}")
384
+ elif isinstance(additional, dict):
385
+ for key in extras:
386
+ _validate_schema_node(instance[key], additional, f"{path}.{key}", root_schema, errors)
387
+
388
+
389
+ def validate_array(
390
+ instance: Any,
391
+ schema: dict[str, Any],
392
+ path: str,
393
+ root_schema: dict[str, Any],
394
+ errors: list[str],
395
+ ) -> None:
396
+ if not isinstance(instance, list):
397
+ return
398
+
399
+ item_schema = schema.get("items")
400
+ if item_schema:
401
+ for index, item in enumerate(instance):
402
+ _validate_schema_node(item, item_schema, f"{path}[{index}]", root_schema, errors)
403
+
404
+ if schema.get("uniqueItems"):
405
+ seen: set[str] = set()
406
+ duplicates: list[Any] = []
407
+ for item in instance:
408
+ fingerprint = json.dumps(item, ensure_ascii=False, sort_keys=True)
409
+ if fingerprint in seen:
410
+ duplicates.append(item)
411
+ seen.add(fingerprint)
412
+ if duplicates:
413
+ errors.append(f"{path}: duplicate array values {duplicates!r}")
414
+
415
+
416
+ def validate_cross_field_rules(sections: dict[str, str], payload: dict[str, Any]) -> list[str]:
417
+ errors: list[str] = []
418
+ result = payload.get("AppAssistantResult", {})
419
+
420
+ request_intent = result.get("request_intent")
421
+ execution_path = result.get("execution_path") or {}
422
+ runtime = result.get("runtime_state") or {}
423
+ delivery = result.get("delivery_state")
424
+ promotion = result.get("promotion_result")
425
+ actions = result.get("actions_performed", [])
426
+ next_action = normalize_space(result.get("next_action"))
427
+ orchestration_state = normalize_space(result.get("orchestration_state"))
428
+
429
+ errors.extend(validate_next_action_vocabulary(next_action))
430
+
431
+ phase = normalize_space(runtime.get("phase"))
432
+ combined_text = "\n".join(
433
+ [
434
+ sections["### Actions Performed"],
435
+ sections["### Current Health"],
436
+ sections["### Blocking Issue"],
437
+ sections["### Next Step"],
438
+ "\n".join(str(action.get("details", "")) for action in actions if isinstance(action, dict)),
439
+ ]
440
+ ).lower()
441
+
442
+ if delivery is not None and not has_executed_skill(actions, "rainbond-delivery-verifier"):
443
+ errors.append("delivery_state can be non-null only after rainbond-delivery-verifier has run")
444
+
445
+ if promotion is not None:
446
+ if request_intent != "dev_to_test_promotion":
447
+ errors.append("promotion_result can be non-null only when request_intent=dev_to_test_promotion")
448
+ if not isinstance(delivery, dict) or delivery.get("status") != "delivered":
449
+ errors.append("promotion_result can be non-null only after source delivery_state.status=delivered")
450
+ if not has_executed_skill(actions, "rainbond-app-version-assistant"):
451
+ errors.append("promotion_result requires rainbond-app-version-assistant to have run")
452
+
453
+ if isinstance(delivery, dict) and delivery.get("status") == "delivered-but-needs-manual-validation":
454
+ if promotion is not None:
455
+ errors.append("delivered-but-needs-manual-validation must not enter promotion_result")
456
+ if points_to_promotion(next_action):
457
+ errors.append("delivered-but-needs-manual-validation must stop before promotion")
458
+ if has_executed_skill(actions, "rainbond-app-version-assistant"):
459
+ errors.append("delivered-but-needs-manual-validation must not run rainbond-app-version-assistant")
460
+
461
+ if phase == "capacity_blocked":
462
+ if points_to_delivery_verification(next_action):
463
+ errors.append("capacity_blocked must not point next_action to delivery verification")
464
+ if isinstance(delivery, dict) and delivery.get("status") in {
465
+ "delivered",
466
+ "delivered-but-needs-manual-validation",
467
+ }:
468
+ errors.append("capacity_blocked is incompatible with a delivered delivery_state")
469
+
470
+ if phase == "code_or_build_handoff_needed" or points_to_code_handoff(next_action):
471
+ errors.extend(validate_code_or_build_boundary(actions, sections))
472
+
473
+ if execution_path.get("requested_kind") == "source":
474
+ if execution_path.get("resolved_kind") != "source" and not is_identity_ambiguous(
475
+ orchestration_state,
476
+ next_action,
477
+ ):
478
+ errors.append("source-backed orchestration must not silently fallback to package/image/template")
479
+ if has_executed_skill(actions, "rainbond-template-installer"):
480
+ errors.append("source-backed orchestration must not execute rainbond-template-installer silently")
481
+ for pattern in FORBIDDEN_FALLBACK_PATTERNS:
482
+ if re.search(pattern, combined_text):
483
+ errors.append("source-backed orchestration must not silently fallback to package/image/template")
484
+ break
485
+
486
+ if isinstance(delivery, dict) and delivery.get("status") == "delivered" and request_intent == "source_app_delivery":
487
+ if promotion is not None:
488
+ errors.append("source_app_delivery runs must stop without promotion_result after delivered")
489
+ if "stop" not in next_action.lower():
490
+ errors.append("delivered source_app_delivery runs must stop instead of continuing")
491
+
492
+ if is_identity_ambiguous(orchestration_state, next_action):
493
+ if delivery is not None:
494
+ errors.append("identity-ambiguous runs must not emit delivery_state")
495
+ if promotion is not None:
496
+ errors.append("identity-ambiguous runs must not emit promotion_result")
497
+ if runtime not in ({}, None):
498
+ phase_value = runtime.get("phase")
499
+ if phase_value is not None:
500
+ errors.append("identity-ambiguous runs must stop before emitting canonical runtime_state.phase")
501
+ if not asks_user_to_choose_identity(next_action):
502
+ errors.append("identity-ambiguous runs must stop and ask the user to choose the team/app identity")
503
+ for blocked_skill in (
504
+ "rainbond-fullstack-bootstrap",
505
+ "rainbond-fullstack-troubleshooter",
506
+ "rainbond-delivery-verifier",
507
+ "rainbond-app-version-assistant",
508
+ ):
509
+ if has_executed_skill(actions, blocked_skill):
510
+ errors.append(f"identity-ambiguous runs must stop before executing {blocked_skill}")
511
+
512
+ if isinstance(delivery, dict):
513
+ errors.extend(validate_delivery_summary(delivery))
514
+
515
+ if isinstance(promotion, dict):
516
+ testing_delivery = promotion.get("testing_delivery_state")
517
+ if isinstance(testing_delivery, dict):
518
+ errors.extend(validate_delivery_summary(testing_delivery, path_prefix="promotion_result.testing_delivery_state"))
519
+
520
+ return errors
521
+
522
+
523
+ def validate_next_action_vocabulary(next_action: str) -> list[str]:
524
+ """next_action must come from the canonical vocabulary (see SKILL.md).
525
+
526
+ Fixed phrases match verbatim after whitespace normalization. Template phrases
527
+ match by their literal prefix + suffix so the <slot> can carry any value.
528
+ """
529
+ candidate = normalize_space(next_action)
530
+ if not candidate:
531
+ # Empty / missing next_action is already caught by the schema (non_empty_string).
532
+ return []
533
+
534
+ if candidate in CANONICAL_NEXT_ACTIONS:
535
+ return []
536
+
537
+ for template in CANONICAL_NEXT_ACTION_TEMPLATES:
538
+ if matches_next_action_template(candidate, template):
539
+ return []
540
+
541
+ return [
542
+ "next_action must be drawn from the canonical vocabulary in SKILL.md "
543
+ f"(fixed phrase verbatim, or a template with its slot filled); got {next_action!r}"
544
+ ]
545
+
546
+
547
+ def matches_next_action_template(candidate: str, template: dict[str, str]) -> bool:
548
+ prefix = normalize_space(template["prefix"])
549
+ suffix = normalize_space(template["suffix"])
550
+ lowered = candidate.lower()
551
+
552
+ if prefix and not lowered.startswith(prefix.lower()):
553
+ return False
554
+ if suffix and not lowered.endswith(suffix.lower()):
555
+ return False
556
+ if len(candidate) < len(prefix) + len(suffix):
557
+ return False
558
+ return True
559
+
560
+
561
+ def validate_delivery_summary(summary: dict[str, Any], path_prefix: str = "delivery_state") -> list[str]:
562
+ errors: list[str] = []
563
+ status = summary.get("status")
564
+ preferred_access_url = summary.get("preferred_access_url")
565
+ verification_mode = summary.get("verification_mode")
566
+ blocker = summary.get("blocker")
567
+ next_action = summary.get("verifier_next_action")
568
+
569
+ if status == "delivered":
570
+ if not preferred_access_url:
571
+ errors.append(f"{path_prefix}.status=delivered requires preferred_access_url")
572
+ if verification_mode != "verified":
573
+ errors.append(f"{path_prefix}.status=delivered requires verification_mode=verified")
574
+ if blocker is not None:
575
+ errors.append(f"{path_prefix}.status=delivered requires blocker=null")
576
+ if next_action != "stop":
577
+ errors.append(f"{path_prefix}.status=delivered requires verifier_next_action=stop")
578
+
579
+ if status == "delivered-but-needs-manual-validation":
580
+ if not preferred_access_url:
581
+ errors.append(
582
+ f"{path_prefix}.status=delivered-but-needs-manual-validation requires preferred_access_url"
583
+ )
584
+ if verification_mode not in {"manual_validation_needed", "inferred"}:
585
+ errors.append(
586
+ f"{path_prefix}.status=delivered-but-needs-manual-validation requires "
587
+ "verification_mode=inferred or manual_validation_needed"
588
+ )
589
+ if blocker is not None:
590
+ errors.append(f"{path_prefix}.status=delivered-but-needs-manual-validation requires blocker=null")
591
+ if next_action != "manual_url_validation":
592
+ errors.append(
593
+ f"{path_prefix}.status=delivered-but-needs-manual-validation requires "
594
+ "verifier_next_action=manual_url_validation"
595
+ )
596
+
597
+ if status == "blocked" and blocker is None:
598
+ errors.append(f"{path_prefix}.status=blocked requires blocker")
599
+
600
+ if status == "partially-delivered" and next_action == "stop":
601
+ errors.append(f"{path_prefix}.status=partially-delivered cannot use verifier_next_action=stop")
602
+
603
+ return errors
604
+
605
+
606
+ def validate_code_or_build_boundary(actions: list[dict[str, Any]], sections: dict[str, str]) -> list[str]:
607
+ errors: list[str] = []
608
+ action_text = "\n".join(str(action.get("details", "")) for action in actions if isinstance(action, dict)).lower()
609
+ action_text += "\n" + sections["### Actions Performed"].lower()
610
+
611
+ for pattern in FORBIDDEN_CODE_HANDOFF_ACTION_PATTERNS:
612
+ if re.search(pattern, action_text):
613
+ errors.append(
614
+ "code_or_build_handoff_needed must not include code edits, local tests, commit, or push"
615
+ )
616
+ break
617
+
618
+ return errors
619
+
620
+
621
+ def validate_prose_consistency(sections: dict[str, str], payload: dict[str, Any]) -> list[str]:
622
+ errors: list[str] = []
623
+ result = payload["AppAssistantResult"]
624
+
625
+ project = result["project"]
626
+ environment = result["environment"]
627
+ runtime = result.get("runtime_state") or {}
628
+ delivery = result.get("delivery_state")
629
+ promotion = result.get("promotion_result")
630
+ actions = result.get("actions_performed", [])
631
+
632
+ project_state = sections["### Project State"]
633
+ actions_section = sections["### Actions Performed"]
634
+ current_health = sections["### Current Health"]
635
+ blocking_issue = sections["### Blocking Issue"]
636
+ next_step = sections["### Next Step"]
637
+
638
+ if not contains_normalized(project_state, result["orchestration_state"]):
639
+ errors.append("Project State prose must mention orchestration_state")
640
+
641
+ if not contains_normalized(project_state, environment["name"]):
642
+ errors.append("Project State prose must mention environment.name")
643
+
644
+ for identity_value in project["identity"].values():
645
+ if identity_value is None:
646
+ continue
647
+ if not contains_normalized(project_state, identity_value):
648
+ errors.append(f"Project State prose must mention identity value {identity_value!r}")
649
+
650
+ if runtime and runtime.get("phase") is not None and not contains_normalized(current_health, runtime["phase"]):
651
+ errors.append("Current Health prose must mention runtime_state.phase")
652
+
653
+ if isinstance(delivery, dict) and not (
654
+ contains_normalized(current_health, delivery["status"])
655
+ or contains_normalized(blocking_issue, delivery["status"])
656
+ ):
657
+ errors.append("Current Health or Blocking Issue prose must mention delivery_state.status")
658
+
659
+ blocker_text = None
660
+ if runtime:
661
+ blocker_text = runtime.get("blocker")
662
+ if not blocker_text and isinstance(delivery, dict):
663
+ blocker_text = delivery.get("blocker")
664
+ if blocker_text and not contains_normalized(blocking_issue, blocker_text):
665
+ errors.append("Blocking Issue prose must align with the structured blocker summary")
666
+
667
+ if not contains_normalized(next_step, result["next_action"]):
668
+ errors.append("Next Step prose must match next_action")
669
+
670
+ for action in actions:
671
+ skill = action.get("skill")
672
+ if not skill:
673
+ continue
674
+ if skill == "inspection-only":
675
+ continue
676
+ if not contains_normalized(actions_section, skill):
677
+ errors.append(f"Actions Performed prose must mention skill {skill!r}")
678
+
679
+ if promotion is not None:
680
+ testing_app = promotion.get("testing_app", {})
681
+ testing_app_name = testing_app.get("app_name")
682
+ if testing_app_name and not (
683
+ contains_normalized(next_step, testing_app_name) or contains_normalized(current_health, testing_app_name)
684
+ ):
685
+ errors.append("prose must mention testing_app.app_name when promotion_result is present")
686
+
687
+ return errors
688
+
689
+
690
+ def normalize_space(text: Any) -> str:
691
+ if text is None:
692
+ return ""
693
+ return " ".join(str(text).split())
694
+
695
+
696
+ def normalize_for_match(text: Any) -> str:
697
+ normalized = normalize_space(text).lower()
698
+ normalized = normalized.replace("_", " ").replace("-", " ")
699
+ return " ".join(normalized.split())
700
+
701
+
702
+ def contains_normalized(haystack: Any, needle: Any) -> bool:
703
+ if needle is None:
704
+ return True
705
+ return normalize_for_match(needle) in normalize_for_match(haystack)
706
+
707
+
708
+ def has_executed_skill(actions: list[dict[str, Any]], skill_name: str) -> bool:
709
+ executed_statuses = {"completed", "complete", "succeeded", "success", "finished", "stopped"}
710
+ for action in actions:
711
+ if not isinstance(action, dict):
712
+ continue
713
+ if action.get("skill") != skill_name:
714
+ continue
715
+ status = normalize_for_match(action.get("status"))
716
+ if status in executed_statuses:
717
+ return True
718
+ return False
719
+
720
+
721
+ def points_to_delivery_verification(next_action: str) -> bool:
722
+ lowered = next_action.lower()
723
+ return "delivery verifier" in lowered or "delivery verification" in lowered
724
+
725
+
726
+ def points_to_promotion(next_action: str) -> bool:
727
+ lowered = next_action.lower()
728
+ return any(
729
+ needle in lowered
730
+ for needle in [
731
+ "promotion",
732
+ "snapshot",
733
+ "testing app",
734
+ "version-assistant",
735
+ "version assistant",
736
+ "dev-to-test",
737
+ ]
738
+ )
739
+
740
+
741
+ def points_to_code_handoff(next_action: str) -> bool:
742
+ lowered = next_action.lower()
743
+ return "code/build" in lowered or "code build" in lowered or "handoff" in lowered
744
+
745
+
746
+ def is_identity_ambiguous(orchestration_state: str, next_action: str) -> bool:
747
+ combined = f"{orchestration_state} {next_action}".lower()
748
+ return "ambiguous" in combined or "choose the team/app identity" in combined
749
+
750
+
751
+ def asks_user_to_choose_identity(next_action: str) -> bool:
752
+ lowered = next_action.lower()
753
+ return "ask the user" in lowered and "identity" in lowered or "choose the team/app identity" in lowered
754
+
755
+
756
+ def validate_expected_fixture(
757
+ sections: dict[str, str],
758
+ payload: dict[str, Any],
759
+ expected: dict[str, Any],
760
+ ) -> list[str]:
761
+ errors: list[str] = []
762
+ assertions = expected.get("assert", {})
763
+
764
+ for path, expected_value in assertions.get("equal", {}).items():
765
+ actual = get_path(payload, path)
766
+ if actual != expected_value:
767
+ errors.append(f"{path}: expected {expected_value!r}, got {actual!r}")
768
+
769
+ for path, expected_value in assertions.get("contains", {}).items():
770
+ actual = get_path(payload, path)
771
+ errors.extend(assert_contains(path, actual, expected_value))
772
+
773
+ for path, forbidden_value in assertions.get("excludes", {}).items():
774
+ actual = get_path(payload, path)
775
+ errors.extend(assert_excludes(path, actual, forbidden_value))
776
+
777
+ prose_body = "\n\n".join(
778
+ [
779
+ sections["### Project State"],
780
+ sections["### Actions Performed"],
781
+ sections["### Current Health"],
782
+ sections["### Blocking Issue"],
783
+ sections["### Next Step"],
784
+ ]
785
+ )
786
+
787
+ for needle in assertions.get("prose_contains", []):
788
+ if needle not in prose_body:
789
+ errors.append(f"prose is missing required text: {needle!r}")
790
+
791
+ for needle in assertions.get("prose_not_contains", []):
792
+ if needle in prose_body:
793
+ errors.append(f"prose contains forbidden text: {needle!r}")
794
+
795
+ return errors
796
+
797
+
798
+ def get_path(payload: Any, dotted_path: str) -> Any:
799
+ current = payload
800
+ for token in dotted_path.split("."):
801
+ if isinstance(current, dict) and token in current:
802
+ current = current[token]
803
+ continue
804
+ raise ValidationFailure(f"missing fixture assertion path: {dotted_path}")
805
+ return current
806
+
807
+
808
+ def assert_contains(path: str, actual: Any, expected: Any) -> list[str]:
809
+ if isinstance(actual, list):
810
+ missing = [item for item in expected if item not in actual]
811
+ if missing:
812
+ return [f"{path}: missing expected list items {missing!r}"]
813
+ return []
814
+
815
+ if isinstance(actual, dict):
816
+ missing_keys = [key for key in expected if key not in actual]
817
+ if missing_keys:
818
+ return [f"{path}: missing expected mapping keys {missing_keys!r}"]
819
+ mismatches = [
820
+ f"{key!r}: expected {value!r}, got {actual[key]!r}"
821
+ for key, value in expected.items()
822
+ if actual[key] != value
823
+ ]
824
+ if mismatches:
825
+ return [f"{path}: mapping mismatches: {', '.join(mismatches)}"]
826
+ return []
827
+
828
+ if isinstance(actual, str):
829
+ if str(expected) not in actual:
830
+ return [f"{path}: expected substring {expected!r} not found"]
831
+ return []
832
+
833
+ return [f"{path}: contains assertion is unsupported for type {type_name(actual)}"]
834
+
835
+
836
+ def assert_excludes(path: str, actual: Any, forbidden: Any) -> list[str]:
837
+ if isinstance(actual, list):
838
+ present = [item for item in forbidden if item in actual]
839
+ if present:
840
+ return [f"{path}: contains forbidden list items {present!r}"]
841
+ return []
842
+
843
+ if isinstance(actual, dict):
844
+ collisions = []
845
+ for key, value in forbidden.items():
846
+ if key in actual and actual[key] == value:
847
+ collisions.append((key, value))
848
+ if collisions:
849
+ return [f"{path}: contains forbidden mapping entries {collisions!r}"]
850
+ return []
851
+
852
+ if isinstance(actual, str):
853
+ if str(forbidden) in actual:
854
+ return [f"{path}: contains forbidden substring {forbidden!r}"]
855
+ return []
856
+
857
+ return [f"{path}: excludes assertion is unsupported for type {type_name(actual)}"]
858
+
859
+
860
+ def check_for_secret_leaks(response_text: str, structured_yaml: str, payload: Any) -> list[str]:
861
+ errors = check_secret_assignments(response_text, context="reply")
862
+ errors.extend(check_secret_assignments(structured_yaml, context="structured output"))
863
+ errors.extend(check_secret_keys(payload, "$"))
864
+ return errors
865
+
866
+
867
+ def check_secret_assignments(text: str, context: str) -> list[str]:
868
+ errors: list[str] = []
869
+
870
+ for line in text.splitlines():
871
+ stripped = line.strip()
872
+ if not stripped:
873
+ continue
874
+
875
+ keyword_match = re.search(
876
+ r"(?i)\b(password|secret|token|api[_-]?key|private[_-]?key|certificate|authorization|cookie)\b"
877
+ r"[^\n:=]{0,32}[:=]\s*(.+)$",
878
+ stripped,
879
+ )
880
+ if keyword_match:
881
+ value = keyword_match.group(2).strip().strip("'\"")
882
+ lowered = value.lower()
883
+ if lowered in {"null", "true", "false", "***", "[masked]", "<masked>", "redacted", "<redacted>"}:
884
+ continue
885
+ if value.startswith("$"):
886
+ continue
887
+ errors.append(f"{context} appears to contain unmasked secret material: {stripped!r}")
888
+ continue
889
+
890
+ env_secret_match = re.search(
891
+ r"(?i)\b[A-Z0-9_]*(PASSWORD|SECRET|TOKEN|API(?:_|-)?KEY|PRIVATE(?:_|-)?KEY|CERT(?:IFICATE)?|COOKIE)"
892
+ r"[A-Z0-9_]*\b\s*[:=]\s*(.+)$",
893
+ stripped,
894
+ )
895
+ if env_secret_match:
896
+ value = env_secret_match.group(2).strip().strip("'\"")
897
+ lowered = value.lower()
898
+ if lowered in {"null", "true", "false", "***", "[masked]", "<masked>", "redacted", "<redacted>"}:
899
+ continue
900
+ if value.startswith("$"):
901
+ continue
902
+ errors.append(f"{context} appears to contain unmasked secret env material: {stripped!r}")
903
+ continue
904
+
905
+ auth_match = re.search(r"(?i)\bauthorization\b\s*:\s*(bearer|grjwt)\s+(\S+)", stripped)
906
+ if auth_match:
907
+ value = auth_match.group(2).strip().strip("'\"")
908
+ if not value.startswith("$"):
909
+ errors.append(f"{context} appears to contain an authorization credential: {stripped!r}")
910
+
911
+ return errors
912
+
913
+
914
+ def check_secret_keys(node: Any, path: str) -> list[str]:
915
+ errors: list[str] = []
916
+
917
+ if isinstance(node, dict):
918
+ for key, value in node.items():
919
+ key_path = f"{path}.{key}"
920
+ lowered_key = key.lower().replace("-", "_")
921
+ if any(keyword in lowered_key for keyword in SECRET_KEYWORDS):
922
+ if isinstance(value, str):
923
+ lowered_value = value.strip().lower()
924
+ if lowered_value not in {masked.lower() for masked in MASKED_VALUES} and not value.startswith("$"):
925
+ errors.append(f"{key_path} appears to expose secret plaintext")
926
+ errors.extend(check_secret_keys(value, key_path))
927
+ elif isinstance(node, list):
928
+ for index, value in enumerate(node):
929
+ errors.extend(check_secret_keys(value, f"{path}[{index}]"))
930
+
931
+ return errors
932
+
933
+
934
+ if __name__ == "__main__":
935
+ sys.exit(main())