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

Writer Tools

Recipe scripts call the platform API through ctx.api_client, which is the same PretorianClient the rest of pretorin uses. This page covers the common write paths and how audit metadata gets stamped automatically when your recipe runs inside an execution context.

How Audit Metadata Gets Stamped

The calling agent opens an execution context with start_recipe(recipe_id, recipe_version, params) and gets back a context_id. Subsequent platform writes from that MCP session pick up the context automatically and stamp:

{
  "producer_kind": "recipe",
  "producer_id": "<recipe_id>",
  "producer_version": "<recipe_version>",
  "recipe_context_id": "<context_id>"
}

When the calling agent makes an MCP-routed write — i.e. the write goes through create_evidence, create_evidence_batch, or update_narrative — it must pass recipe_context_id. The MCP handler reads that context and builds the metadata. Direct agent writes without a recipe context return a structured recipe_required error.

When your script makes a write directly through ctx.api_client (skipping MCP — this is what happens under pretorin recipe run), use ctx.audit_metadata_for_write(...) so the CLI and MCP paths share the same metadata construction:

audit_metadata = ctx.audit_metadata_for_write(
    source_material=source_excerpt,
    evidence_type="configuration",
    source_uri="file://config/rbac.yaml",
    source_label="RBAC configuration",
    source_locator="lines 12-30",
    capture_method="repository_file_read",
)

It stamps producer_kind="recipe", the context’s recipe id/version, the source-type mapping, content hash, and structured source lines. It raises when no recipe context is open, so an unattributed direct write cannot slip through.

The lower-level helper remains available when a script needs fields that this convenience method does not expose:

from pretorin.evidence.audit_metadata import build_recipe_metadata

audit_metadata = build_recipe_metadata(
    body=source_excerpt,
    source_uri="file://config/rbac.yaml",
    source_type="repo_file",
    recipe_id=ctx.recipe_id,
    recipe_version=ctx.recipe_version,
    source_label="RBAC configuration",
    source_locator="lines 12-30",
    source_excerpt=source_excerpt,
    capture_method="repository_file_read",
    redaction_summary=...,
)

evidence = EvidenceCreate(
    control_id="ac-2",
    framework_id="nist-800-53-r5",
    name="...",
    evidence_type="configuration",
    description="RBAC role mapping is enforced in the IdP configuration.",
    artifact_content=composed_markdown,
    audit_metadata=audit_metadata,
)
await ctx.api_client.create_evidence(ctx.system_id, evidence)

Most recipes don’t need the direct path — write through MCP and let the handler stamp.

Read-Side Helpers

You’ll often need to read platform state before writing. The most useful methods on ctx.api_client:

MethodReturnsUse
list_systems()list[dict]Find a system id when one wasn’t passed in ctx.system_id.
get_test_manifest(system_id, stig_id=None)dictThe applicable rules for a system, optionally narrowed to one STIG.
get_control(framework_id, control_id)ControlDetailFull control text + family + status.
get_controls_batch(framework_id, control_ids)dictBatch fetch — cheaper than N calls.
get_control_implementation(...)dictCurrent narrative + status for one control.
get_stig_applicability(system_id)dictWhich STIGs apply to a system.
get_compliance_status(system_id, framework_id)dictHigh-level coverage rollup.
get_source_manifest(system_id)dictVerified-sources state for the system.

Manifest helpers for scanner recipes

If you’re building a scanner recipe, three helpers in pretorin.scanners.manifest cover the common shape:

from pretorin.scanners.manifest import (
    fetch_test_manifest,
    rules_for_stig,
    summarize_results,
)

manifest = await fetch_test_manifest(ctx.api_client, ctx.system_id, stig_id="RHEL_9_STIG")
rules = rules_for_stig(manifest, "RHEL_9_STIG")
results = await my_scanner.execute(rules, config={"target": "local"})
summary = summarize_results(results)

Every built-in scanner recipe uses exactly this pattern. If you’re wrapping a new scanner, copy inspec-baseline/scripts/run_scan.py as a starting point.

Write-Side: Evidence

from pretorin.client.models import EvidenceCreate

evidence = EvidenceCreate(
    control_id="ac-2",
    framework_id="nist-800-53-r5",
    name="RBAC configuration excerpt",
    evidence_type="configuration",
    description="RBAC role mapping is enforced in the IdP configuration.",
    artifact_content=composed_markdown,
    audit_metadata=audit_metadata,
)
result = await ctx.api_client.create_evidence(ctx.system_id, evidence)

For batched writes (much faster when you have ≥ 10):

from pretorin.client.models import EvidenceBatchItemCreate

items = [
    EvidenceBatchItemCreate(
        control_id="ac-2",
        name="...",
        description="Short summary",
        artifact_content=composed_markdown,
        evidence_type="...",
        audit_metadata=audit_metadata,
    )
    for _ in batch
]
response = await ctx.api_client.create_evidence_batch(
    ctx.system_id,
    framework_id="nist-800-53-r5",
    items=items,
)

For binary/unstructured artifacts (a screenshot, a PDF, an exported report), upload the file:

result = await ctx.api_client.upload_evidence(
    system_id=ctx.system_id,
    file_path="/tmp/my-screenshot.png",
    name="Console screenshot — IAM users page",
    evidence_type="screenshot",
    control_id="ac-2",
)

Write-Side: Narratives

await ctx.api_client.update_narrative(
    system_id=ctx.system_id,
    framework_id="nist-800-53-r5",
    control_id="ac-2",
    narrative=composed_text,
    recipe_context_id=ctx.recipe_context_id,
    evidence_ids=["ev-123"],
)

Narrative-producing recipes must cite evidence ids. Open the context with start_recipe(..., evidence_ids=[...]), compose only from those evidence items, then pass the same ids to update_narrative.

Write-Side: Issues

Issue work uses two built-in playbooks rather than a generic note writer:

  • issue-create admits one independently supported gap against one stable expectation. Its context requires the exact unmet expectation, observation and risk bases, risk ratings, clearance condition, and non-empty minimum evidence. add_control_issue enforces that contract, a one-sentence/20-word matcher boundary, a stable expectation-key title, and one Issue per context. After creation, add one minimal draft treatment plan on the same Issue.
  • issue-evaluate reads one existing Issue and its plan/actions/risk history, compares evidence only with the recorded clearance condition, and returns governed verification, one concrete next action, time-bounded acceptance, an explicit void candidate, or already-terminal status. It never creates a child Issue. Lifecycle takes precedence over plan shape: an active acceptance is a valid disposition without a remediation plan. Every nonterminal source-owned RFI/finding/AI-review Issue stays on its exact source workflow; after source reconciliation moves it to verification_pending, its current source result plus supporting evidence is the bounded verification contract. A planless non-source Issue must be reopened before treatment can be added because verification-pending treatment is read-only.

The deprecated control-note-attestation, add_control_note, and resolve_control_note surfaces remain available only for older callers. New recipes should declare produces: issues and use the canonical playbooks.

Write-Side: Test Results

For scanner recipes:

await ctx.api_client.submit_test_results(
    system_id=ctx.system_id,
    benchmark_id="RHEL_9_STIG",
    results=[...],   # list of TestResult dicts
)

Redaction and Markdown Composition

Two helpers from pretorin.evidence:

from pretorin.evidence.redact import redact_secrets
from pretorin.evidence.markdown import compose

redacted_text, redaction = redact_secrets(raw_text)
markdown = compose(
    prose="The IAM trust policy is configured to require MFA for assume-role.",
    snippet=redacted_text,
    snippet_lang="json",
    file_path="iam/trust-policy.json",
)

redact_secrets returns a RedactionResult with counts per secret type (AWS access keys, GitHub tokens, JWTs, etc.). compose turns prose + snippet into the audit-grade markdown body the platform expects on evidence records.

These two are the building blocks of the code-evidence-capture built-in recipe — read its source under src/pretorin/recipes/_recipes/code-evidence-capture/ for a real-world usage pattern.

What Recipe Scripts Should Not Do

  • Don’t bypass ctx.api_client to talk to the platform via raw HTTP. You’ll skip the auth, retry, and error-handling logic the client provides.
  • Don’t construct audit_metadata from scratch. Use ctx.audit_metadata_for_write — or build_recipe_metadata / build_recipe_metadata_from_context for advanced overrides — so the shape stays consistent.
  • Don’t call start_recipe from inside a script. The recipe context is already open — that’s how the script got invoked. Nesting is forbidden.
  • Don’t assume ctx.system_id is set. If your recipe requires a system, raise early with a clear message rather than passing None through to the API.