Skip to content

Commit 9cdf041

Browse files
Trecekclaude
andauthored
[FIX] load_and_validate fail-open on steps-less YAML recipes (#4075)
## Summary `load_and_validate` in `src/autoskillit/recipe/_api.py` initializes `valid = True` (line 315) before a conditional block that gates the entire validation pipeline behind `"steps" in data` (line 333). When a recipe YAML parses to a dict without a `steps` key, the validation pipeline is completely bypassed and the recipe is served as `valid=True` with empty errors and suggestions. All downstream consumers (`open_kitchen`, fleet dispatch, `load_recipe` tool, `get_recipe` resource) trust this result and serve the unvalidated recipe. The architectural fix restructures `load_and_validate` so that validity is **derived unconditionally** from the validation pipeline output (matching the fail-closed pattern already used by `validate_from_path` in `_api_listing.py`), and adds a structural test that makes it impossible for any code path through `load_and_validate` to return `valid=True` without `compute_recipe_validity` having been called. ## Requirements (empty — no issue requirements section found) ## Architecture Impact No architecture diagrams were generated for this fix (no lenses succeeded). ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/remediation-20260611-131720-458826/.autoskillit/temp/rectify/rectify_load_and_validate_fail_open_2026-06-11_131720.md` Closes #4063 🤖 Generated with [Claude Code](https://claude.com/claude-code) via AutoSkillit <!-- autoskillit:pipeline-signature steps=prepare_pr,run_arch_lenses,compose_pr,annotate_pr_diff,review_pr --> ## Token Usage Summary | Step | Model | count | uncached | output | cache_read | peak_ctx | turns | cache_write | time | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | resolve_review* | sonnet | 2 | 386 | 18.4k | 2.7M | 80.0k | 111 | 167.9k | 9m 59s | | dispatch:44d3705c-3f62-459b-af5b-9678f2bbcb64* | sonnet | 1 | 114 | 3.6k | 1.2M | 94.6k | 28 | 94.9k | 23m 9s | | plan* | opus[1m] | 1 | 2.0k | 6.4k | 594.9k | 68.7k | 24 | 50.2k | 6m 29s | | verify* | sonnet | 1 | 92 | 9.3k | 469.4k | 53.4k | 24 | 32.5k | 4m 50s | | implement* | MiniMax-M3 | 1 | 725.2k | 5.9k | 0 | 0 | 34 | 0 | 3m 16s | | audit_impl* | sonnet | 1 | 44 | 7.0k | 164.9k | 40.3k | 15 | 26.5k | 2m 48s | | prepare_pr* | MiniMax-M3 | 1 | 310.6k | 2.5k | 0 | 0 | 18 | 0 | 1m 15s | | compose_pr* | MiniMax-M3 | 1 | 175.5k | 1.1k | 0 | 0 | 12 | 0 | 40s | | review_pr* | sonnet | 1 | 164 | 14.0k | 940.7k | 60.0k | 43 | 39.5k | 4m 17s | | dispatch:0145c757-e69c-4878-929a-ba4acb07e3d1* | sonnet | 1 | 322 | 13.7k | 3.0M | 86.3k | 84 | 62.0k | 42m 39s | | **Total** | | | 1.2M | 81.8k | 9.1M | 94.6k | | 473.5k | 1h 39m | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | resolve_review | 48 | 55633.6 | 3497.5 | 384.0 | | dispatch:44d3705c-3f62-459b-af5b-9678f2bbcb64 | 0 | — | — | — | | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 89 | 0.0 | 0.0 | 65.8 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | review_pr | 0 | — | — | — | | dispatch:0145c757-e69c-4878-929a-ba4acb07e3d1 | 360 | 8363.5 | 172.2 | 38.2 | | **Total** | **497** | 18228.4 | 952.6 | 164.6 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | sonnet | 6 | 1.1k | 66.0k | 8.5M | 423.2k | 1h 27m | | opus[1m] | 1 | 2.0k | 6.4k | 594.9k | 50.2k | 6m 29s | | MiniMax-M3 | 3 | 1.2M | 9.4k | 0 | 0 | 5m 12s | --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ade4afb commit 9cdf041

9 files changed

Lines changed: 338 additions & 12 deletions

File tree

src/autoskillit/recipe/_api.py

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -312,7 +312,7 @@ def load_and_validate(
312312
raw = substitute_temp_placeholder(raw, _temp_relpath)
313313
raw = substitute_scripts_placeholder(raw)
314314
suggestions: list[dict[str, Any]] = []
315-
valid = True
315+
valid = False
316316
errors: list[str] = []
317317
recipe = None
318318
active_recipe = None
@@ -330,7 +330,7 @@ def load_and_validate(
330330
data = _load_recipe_dict(match.path, raw_text=raw, temp_dir_relpath=_temp_relpath)
331331
t0 = _t("yaml_parse", t0, name)
332332

333-
if isinstance(data, dict) and "steps" in data:
333+
if isinstance(data, dict):
334334
recipe = _parse_recipe(data)
335335

336336
from autoskillit.recipe.identity import compute_composite_hash # noqa: PLC0415
@@ -469,8 +469,6 @@ def load_and_validate(
469469
t0 = _t("diagram", t0, name)
470470

471471
valid = compute_recipe_validity(errors, semantic_findings, contract_findings)
472-
else:
473-
t0 = _t("yaml_parse", t0, name)
474472

475473
except YAMLError as exc:
476474
logger.warning("Recipe YAML parse error", name=name, exc_info=True)

src/autoskillit/recipe/io.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -502,7 +502,10 @@ def _parse_recipe(data: dict[str, Any]) -> Recipe:
502502
)
503503

504504
steps: dict[str, RecipeStep] = {}
505-
for step_name, step_data in (data.get("steps") or {}).items():
505+
_steps_raw = data.get("steps")
506+
if _steps_raw is not None and not isinstance(_steps_raw, dict):
507+
raise ValueError(f"'steps' must be a mapping, got {type(_steps_raw).__name__!r}")
508+
for step_name, step_data in (_steps_raw or {}).items():
506509
if isinstance(step_data, dict):
507510
step = _parse_step(step_data)
508511
step.name = step_name

src/autoskillit/server/tools/tools_kitchen.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -418,6 +418,15 @@ def get_recipe(name: str) -> str:
418418
except Exception:
419419
logger.warning("get_recipe_failure", recipe=name, stage="load_and_validate", exc_info=True)
420420
return json.dumps({"error": f"Recipe '{name}' composition failed."})
421+
if not result.get("valid", False):
422+
logger.warning("get_recipe_invalid", recipe=name, errors=result.get("errors", []))
423+
return json.dumps(
424+
{
425+
"error": f"Recipe '{name}' failed validation.",
426+
"errors": result.get("errors", []),
427+
"suggestions": result.get("suggestions", []),
428+
}
429+
)
421430
return result.get("content", json.dumps({"error": "Recipe composition failed."}))
422431

423432

src/autoskillit/server/tools/tools_recipe.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,6 +234,8 @@ async def load_recipe(
234234
)
235235
recipe_info = tool_ctx.recipes.find(name, tool_ctx.project_dir)
236236
result = await _apply_triage_gate(result, name, recipe_info=recipe_info)
237+
if not result.get("valid", False):
238+
result["validation_failed"] = True
237239
if ingredients_only:
238240
result = strip_ingredients_only_keys(result)
239241
return json.dumps(result)

tests/arch/test_subpackage_isolation.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -962,13 +962,13 @@ def test_data_directories_are_not_python_packages() -> None:
962962
"co-located with the execution engine that calls them",
963963
),
964964
"tools_kitchen.py": (
965-
1150,
965+
1160,
966966
"REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require "
967967
"inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) "
968968
"for ingredient key validation; splitting would cross import-layer boundaries; "
969969
"backend capability promotion delegated to _promote_capability_keys in _auto_overrides; "
970970
"fail-closed validity gate on both deferred-recall and normal paths adds structural "
971-
"error propagation from LoadRecipeResult",
971+
"error propagation from LoadRecipeResult; get_recipe validity guard adds 9 lines",
972972
),
973973
"tools_execution.py": (
974974
1130,

tests/infra/test_schema_version_convention.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -122,8 +122,8 @@ def _is_yaml_dump(node: ast.expr) -> bool:
122122
("src/autoskillit/server/tools/tools_kitchen.py", 167),
123123
("src/autoskillit/server/tools/tools_kitchen.py", 186),
124124
("src/autoskillit/server/tools/tools_kitchen.py", 220),
125-
("src/autoskillit/server/tools/tools_kitchen.py", 870),
126-
("src/autoskillit/server/tools/tools_kitchen.py", 930),
125+
("src/autoskillit/server/tools/tools_kitchen.py", 879),
126+
("src/autoskillit/server/tools/tools_kitchen.py", 939),
127127
# tools_pipeline_tracker.py — tracker_data dict
128128
("src/autoskillit/server/tools/tools_pipeline_tracker.py", 166),
129129
# tools_status.py — mcp_data dict

tests/recipe/test_api.py

Lines changed: 240 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@
4747
def _setup_project_recipe(tmp_path: Path, name: str, content: str) -> Path:
4848
"""Write a recipe YAML to tmp_path/.autoskillit/recipes/<name>.yaml."""
4949
recipes_dir = tmp_path / ".autoskillit" / "recipes"
50-
recipes_dir.mkdir(parents=True)
50+
recipes_dir.mkdir(parents=True, exist_ok=True)
5151
recipe_path = recipes_dir / f"{name}.yaml"
5252
recipe_path.write_text(content)
5353
return recipe_path
@@ -1262,3 +1262,242 @@ def test_validate_from_path_codex_backend_valid_after_pruning(tmp_path: Path) ->
12621262
if s.get("severity") == Severity.ERROR
12631263
)
12641264
)
1265+
1266+
1267+
# ---------------------------------------------------------------------------
1268+
# load_and_validate fail-closed tests (regression for steps-less YAML bug)
1269+
# ---------------------------------------------------------------------------
1270+
1271+
1272+
_RECIPE_NO_STEPS = """\
1273+
name: test-no-steps
1274+
description: A test recipe with no steps key
1275+
autoskillit_version: "0.3.0"
1276+
kitchen_rules:
1277+
- "Test rule"
1278+
"""
1279+
1280+
_RECIPE_EMPTY_STEPS = """\
1281+
name: test-empty-steps
1282+
description: A test recipe with empty steps
1283+
autoskillit_version: "0.3.0"
1284+
kitchen_rules:
1285+
- "Test rule"
1286+
steps: {}
1287+
"""
1288+
1289+
_RECIPE_NON_DICT_STEPS = """\
1290+
name: test-list-steps
1291+
description: A test recipe with steps as a list
1292+
autoskillit_version: "0.3.0"
1293+
kitchen_rules:
1294+
- "Test rule"
1295+
steps:
1296+
- foo
1297+
- bar
1298+
"""
1299+
1300+
_RECIPE_NON_DICT_ROOT = """\
1301+
- item1
1302+
- item2
1303+
"""
1304+
1305+
_RECIPE_CAMPAIGN_NO_STEPS = """\
1306+
name: test-campaign-no-steps
1307+
description: A campaign recipe with dispatches and no steps
1308+
autoskillit_version: "0.3.0"
1309+
kind: campaign
1310+
kitchen_rules:
1311+
- "Campaign rule"
1312+
dispatches:
1313+
- name: dispatch1
1314+
recipe: some-recipe
1315+
"""
1316+
1317+
1318+
# 1a: steps-less YAML returns valid=False
1319+
def test_load_and_validate_steps_less_yaml_returns_invalid(tmp_path: Path) -> None:
1320+
"""A YAML recipe without a 'steps' key must produce valid=False."""
1321+
import autoskillit.recipe._api_cache as cache_mod
1322+
1323+
cache_mod._LOAD_CACHE.clear()
1324+
_setup_project_recipe(tmp_path, "test-no-steps", _RECIPE_NO_STEPS)
1325+
1326+
from autoskillit.recipe._api import load_and_validate
1327+
1328+
result = load_and_validate("test-no-steps", project_dir=tmp_path)
1329+
1330+
assert result["valid"] is False, (
1331+
f"Steps-less YAML must return valid=False; got valid={result.get('valid')}. "
1332+
f"Errors: {result.get('errors', [])}"
1333+
)
1334+
assert len(result["errors"]) > 0, "Expected at least one error for steps-less YAML"
1335+
assert any("step" in e.lower() for e in result["errors"]), (
1336+
f"Expected an error mentioning steps; got: {result['errors']}"
1337+
)
1338+
assert any(s.get("severity") == Severity.ERROR for s in result.get("suggestions", [])), (
1339+
"Expected at least one ERROR severity suggestion"
1340+
)
1341+
1342+
1343+
# 1b: cached result also returns valid=False
1344+
def test_load_and_validate_steps_less_yaml_not_cached_as_valid(tmp_path: Path) -> None:
1345+
"""After calling load_and_validate with steps-less YAML, the cache must not serve valid=True.""" # noqa: E501
1346+
import autoskillit.recipe._api_cache as cache_mod
1347+
1348+
cache_mod._LOAD_CACHE.clear()
1349+
_setup_project_recipe(tmp_path, "test-no-steps", _RECIPE_NO_STEPS)
1350+
1351+
from autoskillit.recipe._api import load_and_validate
1352+
1353+
result1 = load_and_validate("test-no-steps", project_dir=tmp_path)
1354+
result2 = load_and_validate("test-no-steps", project_dir=tmp_path)
1355+
1356+
assert result1["valid"] is False
1357+
assert result2["valid"] is False, (
1358+
f"Cached result must also be valid=False; got valid={result2.get('valid')}"
1359+
)
1360+
1361+
1362+
# 1c: compute_recipe_validity is always the source of valid
1363+
def test_load_and_validate_always_invokes_compute_recipe_validity(
1364+
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
1365+
) -> None:
1366+
"""Monkey-patch compute_recipe_validity with a sentinel and verify it flows through
1367+
for BOTH with-steps and without-steps recipes. This structurally guarantees that
1368+
no code path through load_and_validate can return a valid value that didn't come
1369+
from compute_recipe_validity.
1370+
"""
1371+
import autoskillit.recipe._api as api_mod
1372+
import autoskillit.recipe._api_cache as cache_mod
1373+
1374+
cache_mod._LOAD_CACHE.clear()
1375+
1376+
_setup_project_recipe(tmp_path, "test-no-steps", _RECIPE_NO_STEPS)
1377+
_setup_project_recipe(tmp_path, "test-recipe-with-rules", _RECIPE_WITH_RULES)
1378+
1379+
sentinel_valid = object()
1380+
monkeypatch.setattr(api_mod, "compute_recipe_validity", lambda *a, **kw: sentinel_valid)
1381+
1382+
from autoskillit.recipe._api import load_and_validate
1383+
1384+
result_no_steps = load_and_validate("test-no-steps", project_dir=tmp_path)
1385+
assert result_no_steps["valid"] is sentinel_valid, (
1386+
f"Steps-less recipe must get valid from compute_recipe_validity sentinel; "
1387+
f"got valid={result_no_steps.get('valid')!r}"
1388+
)
1389+
1390+
cache_mod._LOAD_CACHE.clear()
1391+
result_with_steps = load_and_validate("test-recipe-with-rules", project_dir=tmp_path)
1392+
assert result_with_steps["valid"] is sentinel_valid, (
1393+
f"With-steps recipe must get valid from compute_recipe_validity sentinel; "
1394+
f"got valid={result_with_steps.get('valid')!r}"
1395+
)
1396+
1397+
1398+
# 1d: empty-steps recipe returns valid=False
1399+
def test_load_and_validate_empty_steps_returns_invalid(tmp_path: Path) -> None:
1400+
"""A YAML with steps: {} (present but empty) must produce valid=False."""
1401+
import autoskillit.recipe._api_cache as cache_mod
1402+
1403+
cache_mod._LOAD_CACHE.clear()
1404+
_setup_project_recipe(tmp_path, "test-empty-steps", _RECIPE_EMPTY_STEPS)
1405+
1406+
from autoskillit.recipe._api import load_and_validate
1407+
1408+
result = load_and_validate("test-empty-steps", project_dir=tmp_path)
1409+
1410+
assert result["valid"] is False, (
1411+
f"Empty-steps YAML must return valid=False; got valid={result.get('valid')}. "
1412+
f"Errors: {result.get('errors', [])}"
1413+
)
1414+
assert any("step" in e.lower() for e in result["errors"]), (
1415+
f"Expected error about steps; got: {result['errors']}"
1416+
)
1417+
1418+
1419+
# 1e: non-dict YAML returns valid=False
1420+
def test_load_and_validate_non_dict_root_returns_invalid(tmp_path: Path) -> None:
1421+
"""A YAML that parses to a list (non-dict root) must produce valid=False.
1422+
1423+
Non-dict root YAML cannot be discovered by list_recipes (which catches the
1424+
ValueError and skips the file), so we construct a RecipeInfo directly.
1425+
"""
1426+
import autoskillit.recipe._api_cache as cache_mod
1427+
from autoskillit.core.types import RecipeSource
1428+
from autoskillit.recipe._api import load_and_validate
1429+
from autoskillit.recipe.schema import RecipeInfo
1430+
1431+
cache_mod._LOAD_CACHE.clear()
1432+
recipe_path = _setup_project_recipe(tmp_path, "test-list-root", _RECIPE_NON_DICT_ROOT)
1433+
1434+
info = RecipeInfo(
1435+
name="test-list-root",
1436+
description="",
1437+
source=RecipeSource.PROJECT,
1438+
path=recipe_path,
1439+
content=_RECIPE_NON_DICT_ROOT,
1440+
)
1441+
1442+
result = load_and_validate("test-list-root", project_dir=tmp_path, recipe_info=info)
1443+
1444+
assert result["valid"] is False, (
1445+
f"Non-dict root YAML must return valid=False; got valid={result.get('valid')}"
1446+
)
1447+
1448+
1449+
# 1f: non-dict steps value returns valid=False
1450+
def test_load_and_validate_non_dict_steps_value_returns_invalid(tmp_path: Path) -> None:
1451+
"""A YAML with steps: [foo, bar] (list instead of mapping) must produce valid=False
1452+
with a clear error, not an uncaught AttributeError.
1453+
1454+
Non-dict steps YAML cannot be discovered by list_recipes (the new ValueError
1455+
guard in _parse_recipe causes _collect_recipes to skip it), so we construct
1456+
a RecipeInfo directly.
1457+
"""
1458+
import autoskillit.recipe._api_cache as cache_mod
1459+
from autoskillit.core.types import RecipeSource
1460+
from autoskillit.recipe._api import load_and_validate
1461+
from autoskillit.recipe.schema import RecipeInfo
1462+
1463+
cache_mod._LOAD_CACHE.clear()
1464+
recipe_path = _setup_project_recipe(tmp_path, "test-list-steps", _RECIPE_NON_DICT_STEPS)
1465+
1466+
info = RecipeInfo(
1467+
name="test-list-steps",
1468+
description="A test recipe with steps as a list",
1469+
source=RecipeSource.PROJECT,
1470+
path=recipe_path,
1471+
content=_RECIPE_NON_DICT_STEPS,
1472+
)
1473+
1474+
result = load_and_validate("test-list-steps", project_dir=tmp_path, recipe_info=info)
1475+
1476+
assert result["valid"] is False, (
1477+
f"Non-dict steps value must return valid=False; got valid={result.get('valid')}. "
1478+
f"Suggestions: {result.get('suggestions', [])}"
1479+
)
1480+
1481+
1482+
# 1g: campaign recipe with no steps does not get "must have at least one step" error
1483+
def test_load_and_validate_campaign_no_steps_returns_valid(tmp_path: Path) -> None:
1484+
"""A campaign recipe (kind=campaign) with dispatches and no steps must NOT
1485+
receive the structural "must have at least one step" error.
1486+
1487+
Campaign recipes take the CAMPAIGN early-return path in validate_recipe_structure
1488+
and are validated via their dispatches, not steps. This guards against the
1489+
removal of the ``"steps" in data`` guard breaking campaign recipes.
1490+
"""
1491+
import autoskillit.recipe._api_cache as cache_mod
1492+
1493+
cache_mod._LOAD_CACHE.clear()
1494+
_setup_project_recipe(tmp_path, "test-campaign-no-steps", _RECIPE_CAMPAIGN_NO_STEPS)
1495+
1496+
from autoskillit.recipe._api import load_and_validate
1497+
1498+
result = load_and_validate("test-campaign-no-steps", project_dir=tmp_path)
1499+
1500+
step_errors = [e for e in result.get("errors", []) if "step" in e.lower()]
1501+
assert not step_errors, (
1502+
f"Campaign recipe should not get step-related structural errors; got: {step_errors}"
1503+
)

0 commit comments

Comments
 (0)