fix: guard against logging the caught exception on the flow execution path (#14561)
* fix: guard against logging the caught exception on the flow execution path
logger.error(f"failed: {e}") is wrong twice over. The exception text reaches container
stdout and the local log file no matter how OTLP export is configured, and provider
errors routinely embed the prompt, the completion, or the API key that was rejected.
Withholding log bodies from the export does not help, because this text never needed
the export to leak. It also destroys the triage signal: error() sets no exc_info, so
the exported record carries no error.type, which is the only field an operator has left
once bodies are withheld. The call site trades the one safe field for the one unsafe one.
The logs-boundary work fixed nine of these by hand. Nothing stopped the next one.
Ruff's G004 is not sufficient, measured rather than assumed. Against those nine it
catches five and misses four: it does not know structlog's aerror and aexception, which
is where those four lived. It cannot see the lazy form logger.error("boom: %s", e),
which is what G004's own fix message recommends and which still renders the exception.
It fires on logger.error(f"failed for flow {flow_id}"), which is not this problem. And
logger-objects is matched per import path, of which this repo has at least five.
So: an AST check keyed on the name bound by "except ... as NAME" rather than on
formatting style. A flow id in an f-string is not a finding; a bare aerror is. It
accepts exc_info=e and type(e).__name__, and reports f"{type(e).__name__}: {e}" because
the second slot is still the message.
Scoped to the flow execution path (base/agents, graph, components/models_and_agents),
which is where a leaked exception message carries flow content. That found 22 sites,
not the dozen estimated on the ticket, including three the f-string rule would have
missed. All 22 are fixed here, so the hook gates at zero with no baseline file to drift.
The hook uses "uv run python" rather than a bare "python", matching the majority of the
local hooks. The bare form does not resolve in a uv-managed checkout.
Pre-existing and unrelated: two catalog-policy tests in tests/unit/graph/test_graph.py
fail on this base branch with none of these changes applied. Verified by reverting every
source file and re-running.
* [autofix.ci] apply automated fixes
* [autofix.ci] apply automated fixes (attempt 2/3)
* fix: close two holes in the logged-exception guard
Both found in review, both real.
The keyword allowlist was too wide. It bypassed exception, error and exc as well as
exc_info, on the assumption that an exception-shaped keyword is a traceback channel.
It is not: a structured processor renders error=str(e) into the record like any other
field, so the guard was waving through the exact leak it exists to catch. Only exc_info
carries the traceback rather than a rendered value, so only exc_info is exempt now.
Nested rebinding double-reported. ast.walk flattens the whole subtree and cannot prune,
so skipping a nested "except ... as e" node still visited its children and the same log
call was reported once per binding. The comment claimed the opposite. Replaced with an
explicit recursive descent that stops at a handler rebinding the same name, and confirmed
against a probe that previously reported line 7 twice.
Pruning is by name, so a nested handler binding a different name still descends and
cannot shield a leak from the outer binding. Both behaviours now have tests.
* fix: restamp the two embedded copies of the Prompt component, and stop over-claiming
Review found three things, all confirmed before fixing.
TranslationFlow.json and the youtube bundle's Youtube Analysis.json embed prompt.py
verbatim alongside a code_hash, and both went stale when prompt.py changed here. The
py_autofix job only walks initial_setup/starter_projects, so CI does not catch it, and
PR #14524 restamped the youtube file for the same reason, so this is a miss against
existing precedent rather than a new rule.
Both now carry the updated source and a recomputed hash. The check that matters: the
embedded code in both files hashes to e3714ffe5d15, which is what prompt.py on disk
hashes to, so the copies are byte-identical to the shipped component rather than merely
edited in the same direction.
The guard's docstring led with "the exception text reaches stdout no matter how OTLP
export is configured". That over-promises, because exc_info=e -- the fix this check tells
you to write -- also renders the full traceback to stdout through format_exc_info and
ConsoleRenderer. The real boundary is the export: _OTEL_LOG_SKIP_KEYS drops exc_info and
derives error.type and error.chain instead. Reworded so nobody reads this as "stdout is
now clean".
Added a known-gap note: only the call's own arguments are inspected, so binding the text
first (msg = f"...{e}"; logger.error(msg)) passes. Confirmed with a probe. Catching it
needs dataflow within the handler, which is a much larger check; the direct form is what
the fixed call sites looked like.
* chore: bump lfx-bundles for the restamped youtube starter project
The embedded Prompt component in the youtube bundle's Youtube Analysis.json was restamped
in the previous commit, which counts as a releasable source change. The bundle release
plan gate failed because the version stayed put.
1.1.13 -> 1.1.14, generated with scripts/ci/bundle_release_plan.py update, which the gate
names in its own error message.
Run it against a current base ref. Against a stale one it walks every bundle and bumps all
of them; from the rebased branch it touches only lfx-bundles, which is the only bundle
whose source moved.
---------
Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> G
Gabriel Luiz Freitas Almeida committed
f39fd3103773edcfc951ddb59820c7ccf059c14d
Parent: dce5e29
Committed by GitHub <noreply@github.com>
on 8/16/2026, 6:09:18 PM