Skip to content

Commit c9ed35e

Browse files
Trecekclaude
andauthored
Implementation Plan: T5-P5-A3-WP1 Server-Side Recipe-Read Prohibition & Write-Target Boundary (#4105)
## Summary Add server-side defense-in-depth guards to `_guards.py` that replicate the recipe-read prohibition currently enforced only by the `recipe_read_guard.py` PreToolUse hook, and add write-target boundary checking for `run_cmd`. This makes both prohibitions HARD-enforced inside the MCP tool layer, backend-agnostic (works for Claude Code, Codex, and any future backend), and independent of hook scripts. Closes #4034 ## Implementation Plan Plan file: `/home/talon/projects/autoskillit-runs/impl-20260613-020615-380722/.autoskillit/temp/make-plan/t5-p5-a3-wp1-server-side-recipe-read-guard_plan_2026-06-13_021500.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 | 90 | 26.9k | 3.0M | 136.8k | 77 | 116.9k | 16m 37s | | verify* | sonnet | 1 | 1.3k | 11.3k | 476.4k | 62.4k | 26 | 41.5k | 6m 27s | | implement* | MiniMax-M3 | 1 | 3.4M | 11.7k | 0 | 0 | 92 | 0 | 7m 3s | | fix* | sonnet | 1 | 216 | 14.0k | 2.2M | 117.4k | 75 | 96.5k | 7m 5s | | audit_impl* | sonnet | 1 | 54 | 12.8k | 231.0k | 60.3k | 17 | 41.0k | 7m 8s | | prepare_pr* | MiniMax-M3 | 1 | 194.9k | 2.2k | 0 | 0 | 14 | 0 | 50s | | compose_pr* | MiniMax-M3 | 1 | 178.7k | 2.2k | 0 | 0 | 12 | 0 | 49s | | **Total** | | | 3.8M | 81.0k | 5.9M | 136.8k | | 295.8k | 46m 1s | \* *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 | 292 | 0.0 | 0.0 | 40.0 | | fix | 15 | 144832.0 | 6432.4 | 932.1 | | audit_impl | 0 | — | — | — | | prepare_pr | 0 | — | — | — | | compose_pr | 0 | — | — | — | | **Total** | **307** | 19267.8 | 963.7 | 263.9 | ## Model Usage Breakdown | Model | steps | uncached | output | cache_read | cache_write | time | |-------|-------|----------|--------|------------|-------------|------| | opus[1m] | 1 | 90 | 26.9k | 3.0M | 116.9k | 16m 37s | | sonnet | 3 | 1.6k | 38.1k | 2.9M | 179.0k | 20m 41s | | MiniMax-M3 | 3 | 3.7M | 16.0k | 0 | 0 | 8m 42s | --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 168aefa commit c9ed35e

9 files changed

Lines changed: 304 additions & 3 deletions

File tree

src/autoskillit/server/_guards.py

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,12 @@
66
from pathlib import Path
77
from typing import TYPE_CHECKING, Any
88

9+
import regex as re
10+
911
from autoskillit.core import (
1012
InputContractResolver,
1113
SessionType,
14+
extract_bash_write_targets,
1215
extract_path_arg,
1316
extract_positional_args,
1417
extract_skill_name,
@@ -24,6 +27,17 @@
2427
logger = get_logger(__name__)
2528

2629

30+
RECIPE_READ_DENY_TRIGGER: str = "must not read recipe/skill/agent files directly"
31+
32+
_RECIPE_READ_CMD_PATTERNS: list[re.Pattern[str]] = [
33+
re.compile(r"(?:\.autoskillit|src/autoskillit)/recipes/.*\.ya?ml"),
34+
re.compile(r"src/autoskillit/skills(?:_extended)?/.*/SKILL\.md"),
35+
re.compile(r"src/autoskillit/agents/.*\.md"),
36+
]
37+
38+
_RECIPE_READ_CALLABLE_PATTERN: re.Pattern[str] = re.compile(r"autoskillit\.recipe\.(?!_cmd_rpc)")
39+
40+
2741
def _get_ctx(): # type: ignore[return]
2842
from autoskillit.server._state import _get_ctx as _ctx_fn # circular-break
2943

@@ -132,6 +146,57 @@ def _require_enabled() -> str | None:
132146
return None
133147

134148

149+
def _check_recipe_read_prohibition(
150+
*, cmd: str | None = None, callable_name: str | None = None
151+
) -> str | None:
152+
"""Deny recipe/skill/agent file reads and recipe module callables.
153+
154+
Headless-only: interactive sessions bypass this guard.
155+
Returns gate_error_result JSON on match, None on pass.
156+
"""
157+
if os.environ.get("AUTOSKILLIT_HEADLESS") != "1":
158+
return None
159+
if cmd is not None:
160+
for pattern in _RECIPE_READ_CMD_PATTERNS:
161+
if pattern.search(cmd):
162+
return gate_error_result(
163+
f"run_cmd {RECIPE_READ_DENY_TRIGGER}. "
164+
"Use load_recipe to recall step definitions or the Skill tool "
165+
"for skill instructions."
166+
)
167+
if callable_name is not None:
168+
if _RECIPE_READ_CALLABLE_PATTERN.match(callable_name):
169+
return gate_error_result(
170+
f"run_python {RECIPE_READ_DENY_TRIGGER}. "
171+
"Use load_recipe to recall step definitions."
172+
)
173+
return None
174+
175+
176+
def _check_write_target_boundary(
177+
cmd: str, cwd: str, allowed_prefixes: tuple[str, ...]
178+
) -> str | None:
179+
"""Deny run_cmd writes outside allowed prefix directories.
180+
181+
Fail-open: returns None when allowed_prefixes is empty (no write scope configured).
182+
Returns gate_error_result JSON when a write target falls outside all prefixes.
183+
"""
184+
if not allowed_prefixes:
185+
return None
186+
targets = extract_bash_write_targets(cmd, cwd)
187+
if not targets:
188+
return None
189+
normalized = tuple(os.path.realpath(p).rstrip("/") + "/" for p in allowed_prefixes)
190+
for target in targets:
191+
resolved = os.path.realpath(target)
192+
if not any(resolved.startswith(pfx) for pfx in normalized):
193+
return gate_error_result(
194+
f"run_cmd write target {target!r} is outside allowed write prefixes: "
195+
f"{', '.join(allowed_prefixes)}"
196+
)
197+
return None
198+
199+
135200
def _validate_skill_command(skill_command: str) -> str | None:
136201
"""Return error JSON if skill_command does not start with '/'.
137202

src/autoskillit/server/tools/tools_execution.py

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@
4242
from autoskillit.server._guards import (
4343
_check_dry_walkthrough,
4444
_check_input_contracts,
45+
_check_recipe_read_prohibition,
46+
_check_write_target_boundary,
4547
_require_enabled,
4648
_require_orchestrator_or_higher,
4749
_validate_skill_command,
@@ -283,6 +285,22 @@ def _has_active_locks(order_id: str) -> bool:
283285
return any(v is False for steps in locked_steps.values() for v in steps.values())
284286

285287

288+
def _derive_run_cmd_write_prefixes() -> tuple[str, ...]:
289+
"""Read allowed write prefixes from environment.
290+
291+
Mirrors the env-var resolution logic in hooks/guards/write_guard.py:
292+
AUTOSKILLIT_ALLOWED_WRITE_PREFIXES (colon-separated) takes precedence over
293+
AUTOSKILLIT_ALLOWED_WRITE_PREFIX (single value).
294+
"""
295+
multi = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "")
296+
if multi:
297+
return tuple(p for p in multi.split(":") if p)
298+
single = os.environ.get("AUTOSKILLIT_ALLOWED_WRITE_PREFIX", "")
299+
if single:
300+
return (single,)
301+
return ()
302+
303+
286304
@mcp.tool(tags={"autoskillit", "kitchen", "kitchen-core"}, annotations={"readOnlyHint": True})
287305
@_cancellation_shield(result_type="run_cmd")
288306
@track_response_size("run_cmd")
@@ -307,8 +325,18 @@ async def run_cmd(
307325
return tier_gate
308326
if (gate := _require_enabled()) is not None:
309327
return gate
328+
if (gate := _check_recipe_read_prohibition(cmd=cmd)) is not None:
329+
return gate
330+
if (
331+
gate := _check_write_target_boundary(cmd, cwd, _derive_run_cmd_write_prefixes())
332+
) is not None:
333+
return gate
310334
try:
311335
with structlog.contextvars.bound_contextvars(tool="run_cmd", cwd=cwd):
336+
if not _derive_run_cmd_write_prefixes():
337+
logger.debug(
338+
"run_cmd: no write prefixes configured — write boundary guard inactive"
339+
)
312340
logger.info("run_cmd", cmd=cmd[:80], cwd=cwd)
313341
await _notify(
314342
ctx, "info", f"run_cmd: {cmd[:80]}", "autoskillit.run_cmd", extra={"cwd": cwd}
@@ -398,6 +426,8 @@ async def run_python(
398426
return tier_gate
399427
if (gate := _require_enabled()) is not None:
400428
return gate
429+
if (gate := _check_recipe_read_prohibition(callable_name=callable)) is not None:
430+
return gate
401431
try:
402432
with structlog.contextvars.bound_contextvars(tool="run_python"):
403433
logger.info("run_python", callable=callable, timeout=timeout)

tests/_test_filter.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -254,7 +254,7 @@ class ImportContext(enum.StrEnum):
254254
"_type_tradition_manifest": frozenset({"core"}),
255255
"_step_context": frozenset({"core", "execution", "pipeline", "server"}),
256256
"_execution_marker": frozenset({"core", "execution", "fleet", "server"}),
257-
"bash_write_targets": frozenset({"core", "execution"}),
257+
"bash_write_targets": frozenset({"core", "execution", "server"}),
258258
}
259259

260260
# Narrow per-module cascade for execution/. Modules not listed here fall through

tests/arch/test_subpackage_isolation.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -982,12 +982,13 @@ def test_data_directories_are_not_python_packages() -> None:
982982
"; supports_quota_check bool at call sites replaces resolve_provider (+3 net lines)",
983983
),
984984
"tools_execution.py": (
985-
1130,
985+
1150,
986986
"REQ-CNST-010-E8: execution tool handlers — run_cmd/run_python/run_skill are the "
987987
"three primary execution paths; fail-closed existence gate, empty-closure gate "
988988
"for fabricated skill name rejection, _check_backend_compat fail-closed gate "
989989
"with resolver-absent fallback via extract_skill_name, and fix-required hook "
990-
"dispatch gate add defense-in-depth checks",
990+
"dispatch gate add defense-in-depth checks; server-side recipe-read prohibition "
991+
"and write-target boundary guards add defense-in-depth gate checks",
991992
),
992993
"execution/backends/codex.py": (
993994
1114,

tests/hooks/test_hook_sync.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -100,3 +100,13 @@ def test_ingredient_lock_deny_trigger_sync():
100100
f"server={INGREDIENT_LOCK_DENY_PREFIX!r} vs "
101101
f"hook={INGREDIENT_LOCK_DENY_TRIGGER!r}"
102102
)
103+
104+
105+
def test_recipe_read_deny_trigger_sync():
106+
"""RECIPE_READ_DENY_TRIGGER must be identical in server guards and hook guard."""
107+
from autoskillit.hooks.guards.recipe_read_guard import RECIPE_READ_DENY_TRIGGER as _HOOK
108+
from autoskillit.server._guards import RECIPE_READ_DENY_TRIGGER as _SERVER
109+
110+
assert _SERVER == _HOOK, (
111+
f"Recipe read deny trigger mismatch: server={_SERVER!r} vs hook={_HOOK!r}"
112+
)

tests/server/AGENTS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ Server tool handler unit tests — kitchen, execution, CI, clone, workspace tool
115115
| `test_tools_recipe.py` | Tests for autoskillit server validate_recipe tool and recipe docstring contracts |
116116
| `test_tools_report_bug.py` | Tests for report_bug MCP tool handler and supporting helpers (_parse_fingerprint, _extract_block, _parse_prepare_result, _parse_enrich_result) |
117117
| `test_tools_run_cmd.py` | Tests for run_cmd and run_python MCP tool handlers |
118+
| `test_tools_run_cmd_invariants.py` | Server-side invariant tests for run_cmd: recipe-read prohibition and write-target boundary |
119+
| `test_tools_run_python_invariants.py` | Server-side invariant tests for run_python: recipe-read prohibition |
118120
| `test_tools_run_cmd_unit.py` | Unit tests for run_cmd: observability, timing, and headless gate enforcement |
119121
| `test_tools_run_python.py` | Unit tests for run_python: observability and headless gate enforcement |
120122
| `test_tools_run_python_cwd.py` | Tests for run_python work_dir path resolution: anchors relative output_dir to work_dir |

tests/server/test_guards_module.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,19 @@ def test_require_fleet_doc_mentions_l3():
3030
assert "L3" in doc
3131
assert "L1" in doc
3232
assert "L2" in doc
33+
34+
35+
def test_check_recipe_read_prohibition_importable():
36+
from autoskillit.server._guards import _check_recipe_read_prohibition
37+
38+
doc = _check_recipe_read_prohibition.__doc__ or ""
39+
assert "recipe" in doc.lower()
40+
assert "headless" in doc.lower()
41+
42+
43+
def test_check_write_target_boundary_importable():
44+
from autoskillit.server._guards import _check_write_target_boundary
45+
46+
doc = _check_write_target_boundary.__doc__ or ""
47+
assert "write" in doc.lower()
48+
assert "prefix" in doc.lower()
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""Server-side invariant tests for run_cmd: recipe-read prohibition and write-target boundary."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
7+
import pytest
8+
9+
from autoskillit.server._guards import RECIPE_READ_DENY_TRIGGER
10+
from autoskillit.server.tools.tools_execution import run_cmd
11+
from tests.conftest import _make_result
12+
13+
pytestmark = [pytest.mark.layer("server"), pytest.mark.small]
14+
15+
16+
class TestRecipeReadProhibitionCmd:
17+
"""run_cmd denies recipe/skill/agent file access in headless sessions."""
18+
19+
@pytest.fixture(autouse=True)
20+
def _headless(self, monkeypatch):
21+
monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1")
22+
monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator")
23+
24+
@pytest.mark.anyio
25+
async def test_denies_recipe_yaml_path(self, tool_ctx_kitchen_open):
26+
result = json.loads(await run_cmd(cmd="cat .autoskillit/recipes/deploy.yaml", cwd="/tmp"))
27+
assert result["success"] is False
28+
assert result["subtype"] == "gate_error"
29+
assert RECIPE_READ_DENY_TRIGGER in result["result"]
30+
31+
@pytest.mark.anyio
32+
async def test_denies_src_recipe_yaml(self, tool_ctx_kitchen_open):
33+
result = json.loads(await run_cmd(cmd="head src/autoskillit/recipes/base.yml", cwd="/tmp"))
34+
assert result["subtype"] == "gate_error"
35+
36+
@pytest.mark.anyio
37+
async def test_denies_skill_md_path(self, tool_ctx_kitchen_open):
38+
result = json.loads(
39+
await run_cmd(cmd="cat src/autoskillit/skills/open-kitchen/SKILL.md", cwd="/tmp")
40+
)
41+
assert result["subtype"] == "gate_error"
42+
43+
@pytest.mark.anyio
44+
async def test_denies_skills_extended_skill_md(self, tool_ctx_kitchen_open):
45+
result = json.loads(
46+
await run_cmd(
47+
cmd="cat src/autoskillit/skills_extended/audit-arch/SKILL.md",
48+
cwd="/tmp",
49+
)
50+
)
51+
assert result["subtype"] == "gate_error"
52+
53+
@pytest.mark.anyio
54+
async def test_denies_agent_md_path(self, tool_ctx_kitchen_open):
55+
result = json.loads(
56+
await run_cmd(cmd="cat src/autoskillit/agents/explorer.md", cwd="/tmp")
57+
)
58+
assert result["subtype"] == "gate_error"
59+
60+
@pytest.mark.anyio
61+
async def test_allows_benign_cmd(self, tool_ctx_kitchen_open):
62+
tool_ctx_kitchen_open.runner.push(_make_result(0, "hello\n", ""))
63+
result = json.loads(await run_cmd(cmd="echo hello", cwd="/tmp"))
64+
assert result["success"] is True
65+
66+
@pytest.mark.anyio
67+
async def test_allows_non_recipe_python_file(self, tool_ctx_kitchen_open):
68+
tool_ctx_kitchen_open.runner.push(_make_result(0, "", ""))
69+
result = json.loads(await run_cmd(cmd="cat src/autoskillit/core/__init__.py", cwd="/tmp"))
70+
assert result["success"] is True
71+
72+
@pytest.mark.anyio
73+
async def test_allows_in_non_headless(self, tool_ctx_kitchen_open, monkeypatch):
74+
monkeypatch.delenv("AUTOSKILLIT_HEADLESS", raising=False)
75+
tool_ctx_kitchen_open.runner.push(_make_result(0, "", ""))
76+
result = json.loads(await run_cmd(cmd="cat .autoskillit/recipes/deploy.yaml", cwd="/tmp"))
77+
assert result["success"] is True
78+
79+
80+
class TestWriteTargetBoundaryCmd:
81+
"""run_cmd denies writes outside allowed prefixes; fails open when unset."""
82+
83+
@pytest.fixture(autouse=True)
84+
def _headless(self, monkeypatch):
85+
monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1")
86+
monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator")
87+
88+
@pytest.mark.anyio
89+
async def test_denies_write_outside_allowed_prefix(self, tool_ctx_kitchen_open, monkeypatch):
90+
monkeypatch.setenv("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "/safe/dir")
91+
result = json.loads(await run_cmd(cmd="echo x > /forbidden/file.txt", cwd="/tmp"))
92+
assert result["success"] is False
93+
assert result["subtype"] == "gate_error"
94+
assert "outside allowed write prefixes" in result["result"]
95+
96+
@pytest.mark.anyio
97+
async def test_allows_write_inside_prefix(self, tool_ctx_kitchen_open, monkeypatch):
98+
monkeypatch.setenv("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "/safe/dir")
99+
tool_ctx_kitchen_open.runner.push(_make_result(0, "", ""))
100+
result = json.loads(await run_cmd(cmd="echo x > /safe/dir/file.txt", cwd="/tmp"))
101+
assert result["success"] is True
102+
103+
@pytest.mark.anyio
104+
async def test_allows_any_write_when_no_prefix(self, tool_ctx_kitchen_open):
105+
tool_ctx_kitchen_open.runner.push(_make_result(0, "", ""))
106+
result = json.loads(await run_cmd(cmd="echo x > /anywhere/file.txt", cwd="/tmp"))
107+
assert result["success"] is True
108+
109+
@pytest.mark.anyio
110+
async def test_allows_read_only_cmd_with_prefix(self, tool_ctx_kitchen_open, monkeypatch):
111+
monkeypatch.setenv("AUTOSKILLIT_ALLOWED_WRITE_PREFIXES", "/safe/dir")
112+
tool_ctx_kitchen_open.runner.push(_make_result(0, "", ""))
113+
result = json.loads(await run_cmd(cmd="ls /forbidden/", cwd="/tmp"))
114+
assert result["success"] is True
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Server-side invariant tests for run_python: recipe-read prohibition."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from unittest.mock import AsyncMock
7+
8+
import pytest
9+
10+
from autoskillit.server._guards import RECIPE_READ_DENY_TRIGGER
11+
from autoskillit.server.tools.tools_execution import run_python
12+
13+
pytestmark = [pytest.mark.layer("server"), pytest.mark.small]
14+
15+
16+
class TestRecipeReadProhibitionCallable:
17+
"""run_python denies autoskillit.recipe.* callables in headless sessions."""
18+
19+
@pytest.fixture(autouse=True)
20+
def _headless(self, monkeypatch):
21+
monkeypatch.setenv("AUTOSKILLIT_HEADLESS", "1")
22+
monkeypatch.setenv("AUTOSKILLIT_SESSION_TYPE", "orchestrator")
23+
24+
@pytest.mark.anyio
25+
async def test_denies_recipe_callable(self, tool_ctx_kitchen_open):
26+
result = json.loads(await run_python(callable="autoskillit.recipe.load_recipe"))
27+
assert result["success"] is False
28+
assert result["subtype"] == "gate_error"
29+
assert RECIPE_READ_DENY_TRIGGER in result["result"]
30+
31+
@pytest.mark.anyio
32+
async def test_denies_recipe_submodule_callable(self, tool_ctx_kitchen_open):
33+
result = json.loads(await run_python(callable="autoskillit.recipe.schema.validate"))
34+
assert result["subtype"] == "gate_error"
35+
36+
@pytest.mark.anyio
37+
async def test_allows_cmd_rpc_callable(self, tool_ctx_kitchen_open, monkeypatch):
38+
mock = AsyncMock(return_value={"success": True, "result": "ok"})
39+
monkeypatch.setattr("autoskillit.server.tools.tools_execution._import_and_call", mock)
40+
result = json.loads(await run_python(callable="autoskillit.recipe._cmd_rpc"))
41+
assert result["success"] is True
42+
43+
@pytest.mark.anyio
44+
async def test_allows_cmd_rpc_batch_callable(self, tool_ctx_kitchen_open, monkeypatch):
45+
mock = AsyncMock(return_value={"success": True, "result": "ok"})
46+
monkeypatch.setattr("autoskillit.server.tools.tools_execution._import_and_call", mock)
47+
result = json.loads(await run_python(callable="autoskillit.recipe._cmd_rpc_batch"))
48+
assert result["success"] is True
49+
50+
@pytest.mark.anyio
51+
async def test_allows_non_recipe_callable(self, tool_ctx_kitchen_open, monkeypatch):
52+
mock = AsyncMock(return_value={"success": True, "result": "ok"})
53+
monkeypatch.setattr("autoskillit.server.tools.tools_execution._import_and_call", mock)
54+
result = json.loads(await run_python(callable="autoskillit.core.paths.pkg_root"))
55+
assert result["success"] is True
56+
57+
@pytest.mark.anyio
58+
async def test_allows_in_non_headless(self, tool_ctx_kitchen_open, monkeypatch):
59+
monkeypatch.delenv("AUTOSKILLIT_HEADLESS", raising=False)
60+
mock = AsyncMock(return_value={"success": True, "result": "ok"})
61+
monkeypatch.setattr("autoskillit.server.tools.tools_execution._import_and_call", mock)
62+
result = json.loads(await run_python(callable="autoskillit.recipe.load_recipe"))
63+
assert result["success"] is True

0 commit comments

Comments
 (0)