From f7ccda739f7cbc8d4cc97d1bbc622f8a61da457a Mon Sep 17 00:00:00 2001 From: Matt Piccolella Date: Mon, 11 May 2026 13:50:41 -0700 Subject: [PATCH] Tighten handoff param patterns to block prompt injection via spaces Param values for matter_id and clause are interpolated directly into the steering-prompt templates. Their patterns previously permitted spaces, which would let a hostile document smuggle a natural-language sentence into the prompt through a field that looks like an ID. Restrict both to slug shape (no spaces); descriptive context belongs in the note/event fields, which are never interpolated and are wrapped in the data frame. Also render templates via format_map with an empty-string default so an optional param the template references (e.g. playbook_monitor's clause) degrades gracefully instead of raising KeyError, and ignore __pycache__. --- .gitignore | 2 ++ scripts/orchestrate.py | 19 ++++++++++++++++--- 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index c4f0a29..af69b71 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ /logs/ /outputs/ /.claude/ +__pycache__/ +*.pyc diff --git a/scripts/orchestrate.py b/scripts/orchestrate.py index 65534f1..0087043 100755 --- a/scripts/orchestrate.py +++ b/scripts/orchestrate.py @@ -53,6 +53,13 @@ ALLOWED_TARGETS = { # Closed schema of permitted handoff intents. Parameters are typed and # pattern-constrained. The orchestrator builds the steering prompt from a # per-intent template below — untrusted free text never becomes the prompt. +# +# Pattern rule: parameters that are interpolated into HANDOFF_TEMPLATES must +# stay slug-shaped — no spaces. A space-permitting pattern lets a hostile +# document smuggle a natural-language sentence into the steering prompt +# through a field that looks like an ID. Descriptive context belongs in the +# `note`/`event` fields, which are never interpolated and are wrapped in the +# data frame before reaching the model. HANDOFF_INTENTS: dict[str, dict] = { "slack_send_message": { "required": ["channel", "report_path"], @@ -79,7 +86,7 @@ HANDOFF_INTENTS: dict[str, dict] = { "required": ["matter_id"], "properties": { "matter_id": {"type": "string", "maxLength": 64, - "pattern": r"^[A-Za-z0-9 ._/:#-]+$"}, + "pattern": r"^[A-Za-z0-9._/:#-]+$"}, "note": {"type": "string", "maxLength": 500}, }, }, @@ -87,7 +94,7 @@ HANDOFF_INTENTS: dict[str, dict] = { "required": [], "properties": { "clause": {"type": "string", "maxLength": 80, - "pattern": r"^[A-Za-z0-9 ._/-]+$"}, + "pattern": r"^[A-Za-z0-9._/-]+$"}, "note": {"type": "string", "maxLength": 500}, }, }, @@ -271,7 +278,13 @@ def extract_handoff(text: str, source_agent: str = "unknown") -> dict | None: sanitized_event = sanitize_event(raw_event) if raw_event else "" # Build the steering input from the typed template — NOT from free text. - steering_input = HANDOFF_TEMPLATES[intent].format(**params) + # Render via format_map with a default so optional params that the + # template references (e.g. playbook_monitor's `clause`) degrade to an + # empty string instead of raising KeyError. + class _Defaulted(dict): + def __missing__(self, _key): # noqa: D105 — small render shim + return "" + steering_input = HANDOFF_TEMPLATES[intent].format_map(_Defaulted(params)) if sanitized_event: steering_input += "\n\n" + frame_handoff(source_agent, sanitized_event)