SEC-1: Prompt Safety, HTML Parsing, and Secret-Log Redaction¶
On-demand reference for the SEC-1 cluster. Short rules in CLAUDE.md:
- Wrap untrusted strings at LLM call sites via
wrap_untrusted()fromsynthorg.engine.prompt_safety. - Never call
lxml.html.fromstringdirectly; useHTMLParseGuard. - Never call any
loggerseverity (exception/warning/error/info/debug) witherror=str(exc)(or any wrapper that smugglesstr(exc)through, including[:200],or fallback, f-strings, and**{"error": ...}dict-unpack); uselogger.warning(EVENT, error_type=type(exc).__name__, error=safe_error_description(exc))instead. The rule is global, not credential-bearing-paths-only:logger.exceptionadds traceback frame-locals, andstr(exc)onhttpx.HTTPStatusError/psycopg.Error/ OAuth provider errors embeds URL or POSTed body content into the log record at every severity level.
Untrusted-content fences at LLM call sites¶
Any attacker-controllable string interpolated into an LLM prompt MUST be wrapped via wrap_untrusted(tag, content) from synthorg.engine.prompt_safety, and the enclosing system prompt MUST append untrusted_content_directive(tags) so the model is explicitly told those fences contain untrusted data.
Attacker-controllable surfaces¶
Task title / description, acceptance criteria, artifact payloads, tool results, tool-invocation arguments, code diffs, multi-tenant strategy config, proposal / alert / query fields, rule metadata, triage requirements, generator context, peer-agent contributions in group conversations.
Standard tags¶
The authoritative registry is ALL_FENCE_TAGS in engine/prompt_safety.py (auto-derived from the TAG_* constants); the list below mirrors it.
TAG_TASK_DATATAG_TASK_FACTTAG_UNTRUSTED_ARTIFACTTAG_TOOL_RESULTTAG_TOOL_ARGUMENTSTAG_CODE_DIFFTAG_CONFIG_VALUETAG_CRITERIA_JSONTAG_PEER_CONTRIBUTIONTAG_MEMORY_ENTRYTAG_RESEARCH_SOURCETAG_LIVING_DOCTAG_BRAIN_STATETAG_KNOWLEDGETAG_CONFLICT_POSITIONTAG_DECISION_OPTIONTAG_DECIDER_NAME
Fence breakout protection¶
wrap_untrusted escapes literal </tag> in content (case-insensitively, including whitespace-terminated variants like </tag > or </tag\t>).
Key reference call sites¶
This list is non-exhaustive; treat it as a navigational starting point for new SEC-1 audits. Any LLM call site that interpolates an attacker-controllable string is in scope, even when not listed here.
format_task_instructionTaskLedgerMiddlewareLLMRubricGrader._prepare_payload_textwrap_tool_resultbuild_review_message(semantic_llm_prompt)build_strategic_prompt_sections_encode_decomposer_payloadbuild_task_message/build_system_message(engine/decomposition/llm_prompt.py)separate_analyzer._build_user_message(evolution proposer)LlmSecurityEvaluator._build_messages(tool-invocation arguments viaTAG_TOOL_ARGUMENTS)ChiefOfStaffChat.explain_proposal/.explain_alert/.ask(three surfaces undermeta/chief_of_staff/chat.pyplus directive-append inprompts.pytemplates)CodeModificationStrategy._build_user_prompt(rule metadata + signal context)_BaseSemanticDetector._prompt(four subclasses inengine/classification/semantic_detectors.py)LLMGenerator._build_prompt(client/generators/llm.py)AgentIntake._build_prompt(engine/intake/strategies/agent_intake.py)LLMSynthesisOp._build_user_promptand._build_system_prompt(memory/consolidation/llm_op.py): wraps each entry underTAG_MEMORY_ENTRY; trajectory-context entries reuse the same tag.SuccessMemoryProposer._build_user_messageand module_SYSTEM_PROMPT(memory/procedural/success_proposer.py): execution context is fenced underTAG_TASK_DATA.SafetyClassifier._build_messages(security/safety_classifier.py): the actiondescription(only attacker-controllable field) is fenced underTAG_TASK_DATA; bounded label fields (tool name, action type, risk level) stayhtml.escaped. The system prompt is computed lazily via_system_prompt()to avoid a circular import throughsynthorg.engine.__init__.- Group-conversation prompt builders (peer-contribution wrapping):
build_group_prompt(meta/chief_of_staff/group_prompt.py): fences the conversation history and the human message underTAG_TASK_DATAand this round's peer contributions underTAG_PEER_CONTRIBUTIONrender_agent_system_prompt(engine/agent_persona.py) appends the directive listing bothTAG_TASK_DATAandTAG_PEER_CONTRIBUTION; it is the system prompt every multi-agent caller dispatch uses (communication/multi_agent/agent_caller.py)
Completion config pinning¶
LLM sites that previously invoked provider.complete() without an explicit CompletionConfig now pin temperature + max_tokens at construction (via __init__ params) so prompt-fingerprint stability can be asserted in tests.
Injection detector (tool results)¶
Tool-result interpolation additionally runs an advisory injection-pattern detector (TOOL_INJECTION_PATTERN_DETECTED) covering a closing-tag look-alike for every tag in Standard tags above, plus common override phrases. Coverage is enforced rather than asserted: _FENCE_TAGS in engine/loop_tool_result_fencing.py lists the tags explicitly, and an import-time guard raises when ALL_FENCE_TAGS holds one the list omits, so a new TAG_* constant cannot reach production outside the detector. Each pattern also admits the lenient whitespace variants wrap_untrusted escapes (</task-data >, </task-data\t>). The telemetry sample is scrubbed via scrub_secret_tokens before logging.
HTML parsing: XXE protection¶
Never call lxml.html.fromstring directly on attacker-controlled input. Use HTMLParseGuard in synthorg.tools.html_parse_guard, which:
- Pre-scans for DOCTYPE with SYSTEM/PUBLIC identifiers and any
<!ENTITY>declaration (rejecting viaXXEDetectedError,is_retryable=False). - Parses with a module-scope
lxml.html.HTMLParser(no_network=True, remove_blank_text=True, recover=True, huge_tree=False).
sanitize() catches XXEDetectedError explicitly so the pre-scan's TOOL_HTML_PARSE_XXE_DETECTED event is the single log entry per rejection (no duplicate TOOL_HTML_PARSE_ERROR). Generic parse failures log error=safe_error_description(exc) without exc_info=True so attacker-controlled payload bytes are not serialised via traceback frame locals.
sanitize() answers with the TEXT of anything carrying a tag, which is the reading a fetched page wants. The tool-result door in ToolInvoker calls guard_tool_output() instead. Two shapes are judged: an HTML document (<!doctype html, <html, <head or <body), and a tagged fragment carrying a construct the strip would take (a stripped tag such as <script>, an event handler with a value, quoted or unquoted, the boolean hidden attribute, aria-hidden, or a hidden style spelled any way the strip removes it). A forge issue body and a fetched snippet arrive as fragments and are exactly where an injection hides. Either shape with nothing hidden in it is returned byte for byte, and either that hid something is returned as re-serialised HTML with the hidden and dangerous elements gone; a fragment keeps its own top-level shape rather than the wrapper the parser gives it. An HTML comment is hidden content too, but a fragment whose only concealment is a comment is not parsed for it: source carries comments (an XML manifest, a README, a template), and an XML file re-serialised through an HTML parser loses every self-closing element. The comment is cut out of the text instead (strip_comments) and everything around it is handed back byte for byte. Source code with angle brackets in it and an XML report reach the model as the tool returned them, because an agent edits what it reads and a rewritten read cannot be matched back to its file; keyed on any tag, the door returned TypeScript without its generics and a JUnit report as nothing at all. The fragment trigger is derived from the strip's own patterns in html_parse_strip, so the two cannot drift. A leading XML declaration is dropped before parsing, since lxml refuses one on an already-decoded string and the refusal blanked every XHTML page.
Pending-test report parsing¶
The second XML surface, and the same threat class as the HTML one. The
build/test oracle classifies a skeleton's declared-pending criteria from a
machine-readable test report, and both the report's PATH and its BYTES come
from a file inside a workspace an agent writes, so neither is trusted even
though the manifest is the authority on what is pending.
engine/workspace/environment/pending_report.py::read_report therefore:
- Resolves the declared path and refuses one that escapes the workspace
(
is_relative_toafterresolve(), so a symlink out is refused too), loggingENVIRONMENT_PENDING_REPORT_ESCAPED. - Opens it by DESCENDING from the workspace root one component at a time, each
with
O_DIRECTORY | O_NOFOLLOW.O_NOFOLLOWconstrains only the last component of the name it is handed, so opening the resolved path in a single call leaves every directory above the report free to be a symlink the agent swapped in after step 1 read the tree; descending puts each component in that last position in turn. Windows implements nodir_fdand degrades to opening the resolved path, which is honest rather than partial: the workspaces an agent writes to are Linux containers, and that is where the swap is reachable at all. - Refuses anything that is not a regular file, asked of the DESCRIPTOR rather than of the path. A named pipe passes every path check and then blocks the parse for ever, which on this call path is the API's own event loop.
- Refuses a report over
_MAX_REPORT_BYTESby bounding the READ at that many bytes plus one, never by measuring the file and then parsing it: a size taken off the descriptor describes the file at that instant and binds nothing the parser goes on to pull from it, so a file appended to in between would be parsed whole. One owner of the limit, and it is the one that cannot be overtaken. The parser builds a tree, and an unbounded one is a memory ceiling the agent picks. - Parses with
defusedxml.ElementTreefrom the bytes already taken, never the stdlib parser and never the live descriptor, so an expanded external entity cannot read the backend's filesystem on the agent's behalf and the tree is built from exactly what the ceiling was applied to.
Every refusal classifies the run's pending criteria RED, and none of them
raises, because a report nobody can read is a rework round rather than an
outage. Which event carries it depends on the refusal: a path leaving the
workspace logs ENVIRONMENT_PENDING_REPORT_ESCAPED, while everything else logs
ENVIRONMENT_PENDING_REPORT_UNREADABLE, naming a reason when this module
decided (no report declared, not a regular file, too large, older than the run)
and an error_type when the filesystem or the XML parser did.
Secret-log redaction¶
NEVER use these patterns, anywhere in the codebase:
logger.exception(EVENT, error=str(exc))
logger.warning(EVENT, error=str(exc))
logger.error(EVENT, error=str(exc))
logger.info(EVENT, error=str(exc))
logger.debug(EVENT, error=str(exc))
The rule is unconditional. The risk is most acute on credential-bearing paths (OAuth flows, secret backends, settings encryption, A2A client/gateway, API auth middleware, persistence repos), but the pattern is forbidden globally because:
logger.exceptionattaches a traceback whose serialised frame-locals can leakclient_secret/refresh_token/ Fernet ciphertext sitting on the stack at any call site.str(exc)onhttpx.HTTPStatusError/psycopg.Error/ similar embeds URL or posted credential bodies into the message field. The embedded-URL/body risk is independent of severity: adebug/info/warning/errorcall still ends up shipping the credential to whatever sink the operator wires the logger to. The gate therefore covers all five severity methods.
A site that "doesn't handle credentials today" can be one refactor away from carrying a request body or connection string into its frame.
Use instead:
from synthorg.observability import safe_error_description
logger.warning(EVENT, error_type=type(exc).__name__, error=safe_error_description(exc))
exc_info=True is forbidden by default (the structlog exc-info processor still serialises traceback frame-locals even when the error= field is redacted). For genuine framework-boundary handlers that operate downstream of a frame-local scrubber (e.g., a hardened crash sink), opt out per-line with # lint-allow: exc-info -- <reason> on the same physical line as the exc_info=True, keyword. The reason field is mandatory and non-empty.
Caller-facing detail is preserved via raise ... from exc.
Sensitive keys and their measurements¶
sanitize_sensitive_fields redacts the value of any key matching a broad
sensitive pattern (password|secret|token|api_key|api_secret|authorization|credential|private_key|bearer|session).
The pattern also matches keys naming a quantity of the sensitive thing
rather than the thing: total_tokens, tokens_used, prompt_token_ratio,
min_password_length. Two exemptions keep those readable, and no others:
- A bool is never redacted. One bit carries no secret material, and the
predicate it reports (
has_token,must_change_password) is already named by the key. - A number is redacted unless the key names a quantity, decided by the
_QUANTITY_PARTSvocabulary inobservability/processors.py(tokens,count,ratio,budget,ceiling,seconds, ...) matched against the key's underscore-separated parts.
The second rule is the load-bearing one: a number can perfectly well be the secret (a PIN, a numeric session id, an account number), so exempting every numeric would fail open for exactly those. An unrecognised numeric key is therefore redacted, and the fix for a genuine measurement is to add its quantity word, never to widen the exemption to all numbers.
Belt-and-braces masking¶
The scrub_event_fields structlog processor masks every log record (covering escaped-quote JSON values, URL form values with stray % bytes, and Authorization: headers).
Pre-commit gate¶
scripts/check_logger_exception_str_exc.py enforces three rules unconditionally (no global allowlist, no baseline) for every logger severity (exception, warning, error, info, debug) on bare logger, attribute-chain loggers (self._logger, audit_logger), or any Name whose id contains logger. The exc_info=True rule (rule 2 below) supports a required same-line per-call opt-out marker (# lint-allow: exc-info -- <reason> with a mandatory non-empty reason); this is a per-instance carve-out for genuine framework-boundary handlers, not a global allowlist or list-based baseline:
- Leak-shape rule (
error=value): theerror=value subtree is walked via a customast.walktraversal that excludesCall.argsand class-introspection chains (sof"{type(exc).__name__}",f"{exc.__class__.__name__}",f"{safe_error_description(exc)}"are not flagged) and that flags any of: str(<exc_like>)calls where<exc_like>isName/Attribute/Subscript(coversstr(exc),str(self._inner),str(exc.args[0])).FormattedValueinterpolation with conversion-1/!s/!r/!aof any leaf whose Name id or Attribute terminal-attr matches_EXCEPTION_LEAF_NAMES(exc, e, err, error, exception, cause, original, inner, _inner).- One-level Name-binding indirection:
error_msg = str(exc); ...; error=error_msg(or any RHS leak shape includingf"...{exc}...") -- the alias is collected per-function-scope and flagged when later passed aserror=. -
Wrapper combinations are walked:
str(exc)[:200](Subscript),str(exc) or fallback(BoolOp),str(exc) if cond else fallback(IfExp),str(exc) + " ctx"(BinOp),f"failed: {str(exc)}"(JoinedStr),**{"error": str(exc)}(Dict-unpack). -
exc_info=Truerule: any literalexc_info=Truekwarg on a logger call is flagged, with a per-line# lint-allow: exc-info -- <reason>opt-out (mandatory non-empty reason). The marker must sit on the same physical line as theexc_info=True,keyword so reviewers and tooling can locate the opt-out without scanning the file. -
logger.exception(...)rule: any call whose terminal attribute isexceptionon a logger-like receiver is flagged unconditionally.logger.exception()attaches a full Python traceback whoseformat_exc_infoprocessor serialises frame-locals (in-scopeclient_secret/refresh_token/ Fernet ciphertext) into the log record before thescrub_event_fieldsdeep scrubber reaches it. No per-line opt-out: replace withexcept ... as exc: logger.error(EVENT, ..., error_type=type(exc).__name__, error=safe_error_description(exc)).
The matcher is the source of truth; the gate's docstring (scripts/check_logger_exception_str_exc.py) describes the AST shapes covered and the rationale per-rule. The script's filename is preserved (rather than renamed) so the pre-commit hook ID no-new-logger-exception-str-exc stays stable in .pre-commit-config.yaml and CI job references.
OTLP span redaction posture¶
The structlog secret-log redaction policy above covers the structlog sink only: log records that flow through synthorg.observability.get_logger. OpenTelemetry spans are a separate transport (OTLP exporter), so the structlog exc_info=True ban does not transitively cover spans. Instead, the per-transport rules are:
- Span exception attributes: never call
span.record_exception(exc)in production code paths -- it serialises the full traceback (and frame locals) into the OTLP exporter, bypassing every redaction step the structlog sink applies. The middleware's exception handler insrc/synthorg/api/middleware.pyinstead sets the OTel-semconv attributes directly:
span.set_attribute("exception.type", type(exc).__name__)
span.set_attribute("exception.message", safe_error_description(exc))
span.set_status(Status(StatusCode.ERROR, type(exc).__name__))
The message is scrubbed via safe_error_description so credentials embedded in exception strings (httpx response bodies, psycopg connection strings, OAuth tokens) cannot reach the OTLP exporter.
-
Auto-instrumentation opt-out: when wrapping code in
tracer.start_as_current_span(...), passrecord_exception=Falseandset_status_on_exception=Falseso the OTel SDK's default exception-on-context-exit behaviour does not undo the redaction by stampingstr(exc)(unscrubbed) into the span before theset_attributecalls run. -
Span events: code that calls
span.add_event(name, attributes)is responsible for applyingsafe_error_description(or equivalent scrubbing) to every attribute that may carry exception strings, request bodies, or other attacker-controllable content.
The pre-commit check_logger_exception_str_exc.py gate does not cover OTel spans (it AST-walks logger calls only). New OTel call sites must self-police; reviewers should reject any span.record_exception outside test fixtures.