Skip to content

fix(plugins): sanitize BigQuery analytics rows and build GCS paths per call (v1) - #6808

Open
GWeale wants to merge 4 commits into
v1from
backport-v1-pr20
Open

fix(plugins): sanitize BigQuery analytics rows and build GCS paths per call (v1)#6808
GWeale wants to merge 4 commits into
v1from
backport-v1-pr20

Conversation

@GWeale

@GWeale GWeale commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

Four changes to BigQueryAgentAnalyticsPlugin on the v1 branch, one commit each, re-implemented against v1's code rather than cherry-picked.

  1. Content formatter failure — 7c96e6b8, from 2919bf5b

    • A row whose content_formatter raises holds [FORMATTER_FAILED] in content.
  2. External URIs — 1ba04e1d, from 9adf0113

    • content_parts[].uri keeps scheme, netloc and path only.
    • A URI with userinfo, over 8192 characters, or one urlsplit rejects becomes [REDACTED_SENSITIVE_URI].
  3. Attributes tree — ef4b10f1, from 2919bf5b

    • attributes holds [REDACTED] for _SENSITIVE_KEYS and temp:-scoped keys from extra_attributes and custom_tags.
    • Bounded at depth 50 ([MAX_DEPTH_EXCEEDED]) and 100,000 nodes per invocation ([SANITIZE_BUDGET_EXCEEDED]).
  4. GCS offload paths — 4c34f25f, from 2919bf5b

    • New object names are <date>/<trace_id>/<span_id>_<parse_uid>_c<content_ordinal>_p<part_index><ext>; existing objects are untouched.

is_truncated is set when the attributes or URI pass alters a value. There is no opt-out, so dashboards reading content, content_parts, attributes or is_truncated may need updating. The plugin is opt-in and is not re-exported from google.adk.plugins.

The File Content Compliance check fails for a pre-existing reason; this PR adds no endpoint URL.

GWeale added 4 commits August 17, 2026 23:08
…er raises (v1)

Before, if the configured `content_formatter` raised, the plugin logged a
warning and wrote the original, unformatted payload to BigQuery. The formatter
is the operator's redaction boundary, so a failing formatter published exactly
the content it was installed to remove.

Now a formatter that raises causes the sentinel string `[FORMATTER_FAILED]` to
be written in place of the payload. The warning it logs is a constant message
plus the event type; the exception text is deliberately not interpolated,
because an exception message or traceback can itself quote the protected
content.

Behaviour change: rows whose formatter failed now carry `[FORMATTER_FAILED]` in
the `content` column instead of the original payload.
…ics (v1)

Before, a `file_data` part's URI was written into the `content_parts` column
exactly as supplied. A signed GCS or HTTP URL carries its signature in the
query string and a URI may carry `user:password@` userinfo, so the row stored a
working bearer credential that anyone with read access to the table could
replay.

Now the URI goes through a sanitizer before it is recorded. A URI with userinfo,
one longer than 8192 characters, or one that does not parse is replaced with
`[REDACTED_SENSITIVE_URI]`. Otherwise the query string and fragment are dropped
and the scheme, host and path are kept, which is what identifies the object.
A URI with none of those components is stored unchanged.

Behaviour change: URIs in `content_parts` are no longer resolvable by
copy-paste when they were signed, and a row whose URI was altered now has
`is_truncated` set.
Before, `_enrich_attributes` ran the redacting sanitizer over `usage_metadata`,
`cache_metadata` and `session.state`, but copied `extra_attributes` and
`custom_tags` in untouched and then serialized the result. A session state
delta or a configured tag holding a key such as `api_key` or `refresh_token`
therefore reached the `attributes` column in the clear.

The assembled tree now goes through `_recursive_smart_truncate` once more,
immediately before serialization, so every value in the column has seen the
sensitive-key redaction regardless of which producer put it there.

That pass walks objects the sanitizer never saw before, which exposed a second
problem. `_recursive_smart_truncate` detects cycles by object id, and an object
whose `model_dump`, `dict` or `to_dict` returns a freshly built wrapper defeats
that, because the walk never sees the same id twice and keeps descending. It
now stops at a depth of 50 and substitutes `[MAX_DEPTH_EXCEEDED]`.

Depth is only half a bound, because it says nothing about width. An object that
hands back two fresh children on every access fills the 50 levels beneath it
with tens of millions of nodes, and one such value in a state delta held the
event loop for over a minute in testing. The walk now also carries a budget of
100,000 nodes for the whole invocation and replaces the remainder with
`[SANITIZE_BUDGET_EXCEEDED]`. A directly redacted key spends budget too, so a
wide `temp:`-scoped mapping cannot slip past the bound, and each container loop
stops at the budget rather than emitting one sentinel per remaining element.

The `mock_agent` test fixture now returns itself from `root_agent`, matching
the pattern already used elsewhere in the file, so `root_agent_name` is a real
name instead of a bare mock object.

Behaviour change: the `attributes` column can now contain `[REDACTED]` and
truncation markers where it previously carried raw values, and `is_truncated`
is set when the attributes pass alters anything. A value already truncated by
`_enrich_attributes` gains a second `...[TRUNCATED]` marker.
…(v1)

Before, `_log_event` assigned the event's trace and span ids onto the single
shared `HybridContentParser` instance and then awaited the parse. The offload
path was built from those instance fields after the await, so a second event
arriving in the meantime replaced them and the first event's media was written
under the second event's prefix. Object names were also built from the part
index alone, so two messages in one request, or two events offloading at the
same moment, produced the same name and one overwrote the other.

`parse` and `_parse_content_object` now take the trace and span ids as
keyword arguments, defaulting to the instance fields so existing callers are
unaffected. Each `parse` call generates a unique id that goes into the object
name, along with the index of the message within the request. `_log_event`
passes the ids instead of assigning them, so the shared parser is no longer
mutated.

Behaviour change: offloaded GCS object names gain a unique component and a
message index, so they are no longer predictable from the trace id, span id
and part index.
@GWeale GWeale changed the title fix(plugins): Harden BigQuery agent analytics privacy and GCS pathing (v1) fix(plugins): sanitize BigQuery analytics rows and build GCS paths per call (v1) Aug 19, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants