## Keyboard shortcuts

Press `←` or `→` to navigate between chapters

Press `S` or `/` to search in the book

Press `?` to show this help

Press `Esc` to hide this help

# Script Contract

Every script declared under `scripts:` in `recipe.md` must export a single async function:

    async def run(ctx, **params) -> dict:
        ...

That’s the entire contract. The recipe runner imports the module, calls `run`, awaits the result, and hands it back to the calling agent as the MCP tool response.

## The Signature

    from typing import Any

    async def run(ctx: Any, *, stig_id: str, target: str = "local") -> dict[str, Any]:
        """Run a baseline scan and return per-rule results."""
        ...

Argument | Type | What it is
--- | --- | ---
`ctx` | `RecipeScriptContext` | Per-invocation execution context (see below).
`**params` | varies | The keyword args the agent supplied, validated against `scripts.<name>.params` in the manifest.

The function must be `async`. Use `await` for I/O. Pretorin’s writer tools are async; using sync HTTP or sync subprocess for slow operations will block the recipe runner’s event loop.

The return value must be a JSON-serializable `dict`. Anything with `tuple`, `set`, `datetime`, or custom classes will fail to serialize back through MCP.

## The `ctx` Argument

`RecipeScriptContext` (defined in `src/pretorin/recipes/runner.py`):

    @dataclass
    class RecipeScriptContext:
        system_id: str | None
        framework_id: str | None
        api_client: Any           # PretorianClient — see writer-tools.md
        logger: logging.Logger
        recipe_id: str
        recipe_version: str
        recipe_context_id: str | None

The two you’ll use most:

- `ctx.api_client` — the authenticated `PretorianClient`. Use this to call platform-API methods (`ctx.api_client.create_evidence(...)`, `ctx.api_client.get_test_manifest(...)`, etc.). See Writer tools for the full surface.
- `ctx.logger` — a `logging.Logger` named for the recipe. Prefer this over `print` so the calling agent’s logs stay structured.

`ctx.system_id` is set when the calling agent specified a system at `pretorin_start_recipe` time. Use it for any per-system platform call. If your recipe doesn’t make sense without a system, raise early with a clear error.

`ctx.recipe_context_id` is the active execution context id. The MCP write handlers read it from the session automatically — you only need to pass it explicitly if your script makes a platform write outside the MCP boundary (e.g., a direct `httpx` call against a custom internal endpoint).

## Returning Results

Whatever your script returns becomes the MCP tool response the calling agent reads. Keep it structured: nested dicts the agent can inspect, with clear keys.

    async def run(ctx, *, stig_id: str) -> dict[str, Any]:
        rules = await fetch_rules(ctx.api_client, ctx.system_id, stig_id)
        results = await scan(rules)
        return {
            "stig_id": stig_id,
            "summary": {
                "total": len(results),
                "passed": sum(1 for r in results if r.status == "pass"),
                "failed": sum(1 for r in results if r.status == "fail"),
            },
            "rules": [r.to_dict() for r in results],
        }

Return shapes the agent can pattern-match are easier to act on than freeform prose. Save the prose for the recipe body — let the script return data.

## Imports Inside `scripts/`

The runner adds the recipe’s `scripts/` directory to `sys.path` for the duration of the call, so a sibling module is reachable as a top-level import:

    # scripts/run_scan.py
    from helpers import normalize_results   # reaches scripts/helpers.py

The path is removed after the call returns. You don’t have to `__init__.py`-decorate the directory.

## Error Handling

Don’t swallow exceptions inside `run`. Let them propagate — the runner catches them, logs them, and returns a structured error to the calling agent. Catching and returning a string error makes the agent think the call succeeded.

    # Bad
    async def run(ctx, *, stig_id: str) -> dict[str, Any]:
        try:
            return await fetch(ctx, stig_id)
        except Exception as e:
            return {"error": str(e)}        # agent sees a "successful" call

    # Good
    async def run(ctx, *, stig_id: str) -> dict[str, Any]:
        return await fetch(ctx, stig_id)    # exceptions surface as tool errors

## Patterns

Three shapes cover most recipes:

### Capture-from-source

The recipe pulls a thing (a config file, a snippet of code, a query result), redacts it, and registers an evidence record.

    async def run(ctx, *, file_path: str, line_range: str | None = None) -> dict[str, Any]:
        text = (Path(file_path).read_text()).split("\n")
        snippet = _slice(text, line_range)
        redacted, summary = redact_secrets(snippet)
        composed = compose_audit_markdown(redacted, file_path=file_path, line_range=line_range)
        evidence_id = await ctx.api_client.create_evidence(
            system_id=ctx.system_id,
            ...
        )
        return {"evidence_id": evidence_id, "redaction_summary": summary.to_dict()}

### Wrap-a-scanner

The recipe wraps an external tool (oscap, inspec, az, aws), runs it against the platform’s test manifest, and returns per-rule results.

    async def run(ctx, *, stig_id: str, target: str = "local") -> dict[str, Any]:
        manifest = await fetch_test_manifest(ctx.api_client, ctx.system_id, stig_id=stig_id)
        rules = rules_for_stig(manifest, stig_id)
        scanner = InSpecScanner()
        results = await scanner.execute(rules, config={"target": target})
        return {
            "stig_id": stig_id,
            "summary": summarize_results(results),
        }

The five built-in scanner recipes are exactly this shape — each is a thin adapter over a `pretorin.scanners.*` class.

### Q-and-A attestation

The recipe is the agent collecting human attestations interactively, with no external tool involved. Inputs are structured answers; the recipe just records them.

    async def run(ctx, *, stig_id: str, attestations: list[dict]) -> dict[str, Any]:
        scanner = ManualScanner()
        results = await scanner.execute(rules, config={"attestations": attestations})
        return {"stig_id": stig_id, "summary": summarize_results(results)}

## When to Write Multiple Scripts

If your recipe has steps that the calling agent might want to interleave with reasoning (e.g., “redact, show me, then compose”), expose each step as its own script. The agent can then call them as separate MCP tools and inspect the intermediate output.

The `code-evidence-capture` recipe ships two scripts (`redact_secrets` and `compose_snippet`) for exactly this reason.