Skip to content

Commit 4204cba

Browse files
Trecekclaude
andauthored
Implementation Plan: T5-P5-A4-WP1 — Result Validation Helper with Fail-Closed Envelope (#4144)
## Summary Add a `_validate_result` function to `src/autoskillit/server/tools/_types.py` that enforces output shape invariants on MCP tool result dicts before serialization. The function checks required-key presence, non-None values, `success==True` when present, and non-empty `content` when present. On violation it returns a `json.dumps`-serialized `ToolFailureEnvelope` (fail-closed); on pass it returns `None`. Wire this validator into `open_kitchen` (normal named-recipe branch only — deferred-recall branch exempted per acceptance criteria) and `load_recipe` (unified exit, gated on result validity) with structured logging at each fail-closed site. Closes #4036 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260628-093915-207021/.autoskillit/temp/make-plan/t5-p5-a4-wp1-validate-result-helper_plan_2026-06-28_094800.md` 🤖 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 | |------|-------|-------|----------|--------|------------|----------|-------|-------------|------| | plan* | opus[1m] | 1 | 843 | 26.4k | 985.6k | 97.6k | 44 | 86.5k | 14m 10s | | verify* | sonnet | 1 | 60 | 11.9k | 304.3k | 62.3k | 22 | 43.8k | 6m 34s | | implement* | MiniMax-M3 | 1 | 76.2k | 10.9k | 2.4M | 0 | 93 | 0 | 5m 31s | | fix* | sonnet | 1 | 438 | 51.6k | 5.1M | 147.2k | 140 | 129.2k | 18m 44s | | audit_impl* | sonnet | 1 | 1.3k | 15.2k | 223.8k | 57.0k | 16 | 42.3k | 8m 35s | | prepare_pr* | MiniMax-M3 | 1 | 44.1k | 2.2k | 232.2k | 0 | 16 | 0 | 1m 40s | | compose_pr* | MiniMax-M3 | 1 | 37.2k | 1.8k | 177.0k | 0 | 13 | 0 | 31s | | **Total** | | | 160.2k | 119.9k | 9.4M | 147.2k | | 301.7k | 55m 48s | \* *Step used a non-Anthropic provider; caching behavior may differ.* ## Token Efficiency | Step | LoC Changed | cache_read/LoC | cache_write/LoC | output/LoC | |------|-------------|----------------|-----------------|------------| | plan | 0 | — | — | — | | verify | 0 | — | — | — | | implement | 193 | 12177.9 | 0.0 | 56.4 | | fix | 309 | 16587.2 | 418.0 | 166.9 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **502** | 18722.5 | 601.0 | 238.9 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 843 | 26.4k | 985.6k | 86.5k | 14m 10s | | sonnet | 3 | 1.8k | 78.7k | 5.7M | 215.2k | 33m 54s | | MiniMax-M3 | 3 | 157.5k | 14.9k | 2.8M | 0 | 7m 43s | --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent a775b01 commit 4204cba

10 files changed

Lines changed: 306 additions & 7 deletions

src/autoskillit/server/tools/_types.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77

88
from __future__ import annotations
99

10+
import json
1011
from typing import Any, Literal, TypedDict
1112

1213
from autoskillit.core import ModelTotalEntry, RetryReason
@@ -23,6 +24,7 @@
2324
"ToolFailureEnvelope",
2425
"server_failure_envelope",
2526
"input_failure_envelope",
27+
"_validate_result",
2628
]
2729

2830

@@ -244,3 +246,56 @@ def input_failure_envelope(
244246
stage=stage,
245247
retriable=False,
246248
)
249+
250+
251+
def _validate_result(
252+
result: dict[str, Any],
253+
*,
254+
required_keys: frozenset[str],
255+
tool_name: str,
256+
retriable: bool = False,
257+
) -> str | None:
258+
"""Validate a tool result dict and return a fail-closed envelope on violation.
259+
260+
Returns ``None`` when all invariants hold, or a ``json.dumps``-serialized
261+
``ToolFailureEnvelope`` on the first violation detected.
262+
"""
263+
_stage = f"validate_result:{tool_name}"
264+
for key in sorted(required_keys):
265+
if key not in result:
266+
return json.dumps(
267+
ToolFailureEnvelope(
268+
success=False,
269+
error=f"Missing required key: {key}",
270+
stage=_stage,
271+
retriable=retriable,
272+
)
273+
)
274+
if result[key] is None:
275+
return json.dumps(
276+
ToolFailureEnvelope(
277+
success=False,
278+
error=f"Required key is None: {key}",
279+
stage=_stage,
280+
retriable=retriable,
281+
)
282+
)
283+
if "success" in result and result["success"] is not True:
284+
return json.dumps(
285+
ToolFailureEnvelope(
286+
success=False,
287+
error=f"Result reports failure: success={result['success']!r}",
288+
stage=_stage,
289+
retriable=retriable,
290+
)
291+
)
292+
if "content" in result and not result["content"]:
293+
return json.dumps(
294+
ToolFailureEnvelope(
295+
success=False,
296+
error="Result content is empty",
297+
stage=_stage,
298+
retriable=retriable,
299+
)
300+
)
301+
return None

src/autoskillit/server/tools/tools_kitchen.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
_check_dispatch_feasibility,
6969
filter_steps_by_post_prune,
7070
)
71+
from autoskillit.server.tools._types import _validate_result
7172

7273
logger = get_logger(__name__)
7374

@@ -888,6 +889,20 @@ async def open_kitchen(
888889
if warning:
889890
result["hook_warning"] = warning.strip()
890891

892+
_required_keys = frozenset({"success", "content", "valid"})
893+
if ingredients_only:
894+
_required_keys = _required_keys - {"content"}
895+
_validation_err = _validate_result(
896+
result, required_keys=_required_keys, tool_name="open_kitchen"
897+
)
898+
if _validation_err is not None:
899+
logger.warning(
900+
"open_kitchen_fail_closed",
901+
tool="open_kitchen",
902+
stage="validate_result",
903+
)
904+
return _validation_err
905+
891906
return json.dumps(result)
892907

893908
text = (

src/autoskillit/server/tools/tools_recipe.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
_promote_capability_keys,
3030
)
3131
from autoskillit.server.tools._cancellation_shield import _cancellation_shield
32+
from autoskillit.server.tools._types import _validate_result
3233

3334
logger = get_logger(__name__)
3435

@@ -244,6 +245,19 @@ async def load_recipe(
244245
)
245246
if ingredients_only:
246247
result = strip_ingredients_only_keys(result)
248+
_required_keys: frozenset[str] = frozenset()
249+
if not ingredients_only and result.get("valid", False):
250+
_required_keys = frozenset({"content"})
251+
_validation_err = _validate_result(
252+
result, required_keys=_required_keys, tool_name="load_recipe"
253+
)
254+
if _validation_err is not None:
255+
logger.warning(
256+
"load_recipe_fail_closed",
257+
tool="load_recipe",
258+
stage="validate_result",
259+
)
260+
return _validation_err
247261
return json.dumps(result)
248262
except Exception as exc:
249263
logger.error("load_recipe unhandled exception", exc_info=True)

tests/arch/test_subpackage_isolation.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -967,7 +967,7 @@ def test_data_directories_are_not_python_packages() -> None:
967967
"co-located with the execution engine that calls them",
968968
),
969969
"tools_kitchen.py": (
970-
1290,
970+
1310,
971971
"REQ-CNST-010-E7: kitchen tool handlers — open_kitchen and lock_ingredients require "
972972
"inline validation helpers (_check_override_keys, _build_ingredient_key_suggestions) "
973973
"for ingredient key validation; splitting would cross import-layer boundaries; "

tests/infra/test_schema_version_convention.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -119,11 +119,11 @@ def _is_yaml_dump(node: ast.expr) -> bool:
119119
# _lifespan.py — hooks.json self-heal on startup drift (co-owned with Claude plugin system)
120120
("src/autoskillit/server/_lifespan.py", 90),
121121
# tools_kitchen.py — hook config, quota guard, git_ops_policy, ingredient locks overlay
122-
("src/autoskillit/server/tools/tools_kitchen.py", 204),
123-
("src/autoskillit/server/tools/tools_kitchen.py", 223),
124-
("src/autoskillit/server/tools/tools_kitchen.py", 257),
125-
("src/autoskillit/server/tools/tools_kitchen.py", 1013),
126-
("src/autoskillit/server/tools/tools_kitchen.py", 1073),
122+
("src/autoskillit/server/tools/tools_kitchen.py", 205),
123+
("src/autoskillit/server/tools/tools_kitchen.py", 224),
124+
("src/autoskillit/server/tools/tools_kitchen.py", 258),
125+
("src/autoskillit/server/tools/tools_kitchen.py", 1028),
126+
("src/autoskillit/server/tools/tools_kitchen.py", 1088),
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/server/AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,7 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool
133133
| `test_tools_status_quota_and_db.py` | Tests for server status tools: quota events, telemetry writing, and DB access |
134134
| `test_tools_status_summaries.py` | Tests for server status tools: token and timing summaries |
135135
| `test_tools_types_module.py` | Tests for server/tools/_types.py TypedDict imports and failure envelope factory functions |
136+
| `test_tools_types_validate.py` | Tests for _validate_result helper in server/tools/_types.py — shape invariant enforcement and fail-closed envelope validation |
136137
| `test_tools_workspace.py` | Tests for autoskillit server workspace tools |
137138
| `test_tools_agents.py` | Tests for agent pack registry, MCP resources, and `unlock_agent_pack` |
138139
| `test_track_response_size.py` | Tests for the track_response_size decorator in autoskillit.server._notify |

tests/server/test_tools_kitchen_gate.py

Lines changed: 75 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from __future__ import annotations
44

55
import json
6-
from unittest.mock import AsyncMock, patch
6+
from unittest.mock import AsyncMock, MagicMock, patch
77

88
import pytest
99

@@ -341,3 +341,77 @@ async def test_close_kitchen_drains_orphaned_github_api_entries(tmp_path, monkey
341341
data = json.loads(orphan_path.read_text())
342342
assert data["total_requests"] == 1
343343
assert not log._entries
344+
345+
346+
@pytest.mark.anyio
347+
async def test_open_kitchen_fail_closed_empty_content(monkeypatch, tmp_path):
348+
"""open_kitchen returns fail-closed when content is empty (caught at validity gate)."""
349+
monkeypatch.chdir(tmp_path)
350+
ctx = _make_mock_ctx()
351+
ctx.gate.enabled = True
352+
ctx.gate_infrastructure_ready = True
353+
_load_result = {"valid": True, "content": "", "dispatch_feasible": True}
354+
ctx.recipes.load_and_validate.return_value = _load_result
355+
ctx.recipes.find.return_value = MagicMock(path=tmp_path / "r.yaml")
356+
ctx.config.providers = MagicMock()
357+
ctx.backend = MagicMock()
358+
ctx.backend.name = "test"
359+
with (
360+
patch("autoskillit.server._get_ctx", return_value=ctx),
361+
patch("autoskillit.server.logger"),
362+
patch(
363+
"autoskillit.server.tools.tools_kitchen._apply_triage_gate",
364+
new=AsyncMock(return_value=_load_result),
365+
),
366+
patch(
367+
"autoskillit.server.tools.tools_kitchen._prime_quota_cache",
368+
new_callable=AsyncMock,
369+
),
370+
patch("autoskillit.server.tools.tools_kitchen._write_hook_config"),
371+
):
372+
from autoskillit.server.tools.tools_kitchen import open_kitchen
373+
374+
raw = await open_kitchen(name="test-recipe")
375+
376+
parsed = json.loads(raw)
377+
assert parsed["success"] is False
378+
assert parsed.get("kitchen") == "failed"
379+
assert "error" in parsed
380+
assert "failed validation" in parsed["error"]
381+
382+
383+
@pytest.mark.anyio
384+
async def test_open_kitchen_fail_closed_missing_content(monkeypatch, tmp_path):
385+
"""open_kitchen returns fail-closed when content key is missing (caught at validity gate)."""
386+
monkeypatch.chdir(tmp_path)
387+
ctx = _make_mock_ctx()
388+
ctx.gate.enabled = True
389+
ctx.gate_infrastructure_ready = True
390+
_load_result = {"valid": True, "dispatch_feasible": True}
391+
ctx.recipes.load_and_validate.return_value = _load_result
392+
ctx.recipes.find.return_value = MagicMock(path=tmp_path / "r.yaml")
393+
ctx.config.providers = MagicMock()
394+
ctx.backend = MagicMock()
395+
ctx.backend.name = "test"
396+
with (
397+
patch("autoskillit.server._get_ctx", return_value=ctx),
398+
patch("autoskillit.server.logger"),
399+
patch(
400+
"autoskillit.server.tools.tools_kitchen._apply_triage_gate",
401+
new=AsyncMock(return_value=_load_result),
402+
),
403+
patch(
404+
"autoskillit.server.tools.tools_kitchen._prime_quota_cache",
405+
new_callable=AsyncMock,
406+
),
407+
patch("autoskillit.server.tools.tools_kitchen._write_hook_config"),
408+
):
409+
from autoskillit.server.tools.tools_kitchen import open_kitchen
410+
411+
raw = await open_kitchen(name="test-recipe")
412+
413+
parsed = json.loads(raw)
414+
assert parsed["success"] is False
415+
assert parsed.get("kitchen") == "failed"
416+
assert "error" in parsed
417+
assert "failed validation" in parsed["error"]

tests/server/test_tools_load_recipe.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -580,3 +580,47 @@ async def test_load_recipe_surfaces_validation_failure(
580580
)
581581
assert "errors" in result
582582
assert len(result["errors"]) > 0
583+
584+
585+
class TestLoadRecipeFailClosed:
586+
"""Fail-closed validation for empty and missing content."""
587+
588+
@pytest.fixture(autouse=True)
589+
def _ensure_ctx(self, tool_ctx_kitchen_open):
590+
self.ctx = tool_ctx_kitchen_open
591+
592+
@pytest.mark.anyio
593+
async def test_load_recipe_fail_closed_empty_content(self, monkeypatch, tmp_path):
594+
monkeypatch.chdir(tmp_path)
595+
from autoskillit.recipe._api_cache import _LOAD_CACHE
596+
597+
_LOAD_CACHE.clear()
598+
test_result = {"valid": True, "content": "", "dispatch_feasible": True}
599+
monkeypatch.setattr(self.ctx.recipes, "load_and_validate", lambda *a, **kw: test_result)
600+
monkeypatch.setattr(self.ctx.recipes, "find", lambda *a, **kw: None)
601+
with patch(
602+
"autoskillit.server.tools.tools_recipe._apply_triage_gate",
603+
new=AsyncMock(return_value=test_result),
604+
):
605+
raw = await load_recipe(name="test-recipe")
606+
parsed = json.loads(raw)
607+
assert parsed["success"] is False
608+
assert "content" in parsed["error"].lower()
609+
610+
@pytest.mark.anyio
611+
async def test_load_recipe_fail_closed_missing_content(self, monkeypatch, tmp_path):
612+
monkeypatch.chdir(tmp_path)
613+
from autoskillit.recipe._api_cache import _LOAD_CACHE
614+
615+
_LOAD_CACHE.clear()
616+
test_result = {"valid": True, "dispatch_feasible": True}
617+
monkeypatch.setattr(self.ctx.recipes, "load_and_validate", lambda *a, **kw: test_result)
618+
monkeypatch.setattr(self.ctx.recipes, "find", lambda *a, **kw: None)
619+
with patch(
620+
"autoskillit.server.tools.tools_recipe._apply_triage_gate",
621+
new=AsyncMock(return_value=test_result),
622+
):
623+
raw = await load_recipe(name="test-recipe")
624+
parsed = json.loads(raw)
625+
assert parsed["success"] is False
626+
assert "content" in parsed["error"]

tests/server/test_tools_types_module.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,7 @@ def test_tool_failure_envelope_in_all(self):
126126
assert "ToolFailureEnvelope" in types_all
127127
assert "server_failure_envelope" in types_all
128128
assert "input_failure_envelope" in types_all
129+
assert "_validate_result" in types_all
129130

130131
def test_tool_failure_envelope_distinct_from_kitchen_envelope(self):
131132
from autoskillit.server.tools._types import ToolFailureEnvelope

0 commit comments

Comments
 (0)