Agent-First Data v0.10: Secrets Inside URLs

by Agent-First Kit Contributors

The _secret suffix hides a whole field. But secrets also hide inside values — a token in a query string, a password in a connection URL. v0.10 adds a _url suffix that scrubs them, by convention, without scanning anything.

The _secret suffix redacts a whole field: name it api_key_secret and the value disappears behind ***. That works because the secret is the field.

But secrets are not always their own field. They hide inside other values:

{"endpoint_url": "wss://host:9222/cdp?token=abc123"}
{"database_url": "postgres://app:hunter2@db.internal/prod"}

The token and the password are real credentials, but they live in the middle of a URL that is otherwise safe to log. _secret can’t reach them — the field is a URL, not a secret — and marking the whole field _secret would throw away the host and path an agent needs to reason about. v0.10 closes that gap.

The default still holds

If you control the field name and the whole value is sensitive, nothing changes:

{"api_key_secret": "sk-123", "duration_ms": 12}

api_key_secret redacts to ***. That is still the happy path.

The gap: secrets inside URLs

A URL packs a credential into a structured string in two well-defined places: the userinfo (scheme://user:password@host) and query parameters (?token=...). v0.10 adds a _url suffix that scrubs exactly those, in place, and leaves the rest of the URL intact:

{"endpoint_url": "wss://host:9222/cdp?token_secret=abc123"}

redacts to:

{"endpoint_url": "wss://host:9222/cdp?token_secret=***"}

Scheme, host, port, path, and any benign parameters survive untouched. An agent still sees where the request went; the credential is gone.

A convention, not a scanner

The obvious implementation is tempting and wrong: walk every string value, and if it looks like a URL, scrub it. That scans fields nobody marked, costs work on every payload, and breaks the one rule that makes AFDATA legible — the field name is the schema.

So URL redaction is keyed on the name, exactly like _secret. A field is processed only when it ends in _url/_URL. No other string is examined. The trigger is a suffix check, not a content scan.

The same secret rule, reused

Which query parameters count as secret? The same rule as everywhere else: a parameter whose name ends in _secret, or that you list explicitly in secret_names. There is no built-in table of “sensitive” parameter names — inventing one would be the very guessing the convention exists to replace.

// token_secret matches the suffix rule — scrubbed by default
redact_url_secrets("wss://h/cdp?token_secret=abc");
// → "wss://h/cdp?token_secret=***"

// a legacy ?token= is scrubbed only when you say so
let opts = RedactionOptions { secret_names: vec!["token".into()], ..Default::default() };
redact_url_secrets_with_options("wss://h/cdp?token=abc", &opts);
// → "wss://h/cdp?token=***"

If you own the URL, name the parameter token_secret and it is covered with no configuration. The userinfo password is the one structural exception — it is always a credential by definition, so scheme://user:pass@host always becomes scheme://user:***@host.

The primitive, for URLs in prose

_url fires on a field whose value is a URL. It deliberately does nothing to a free-form message that happens to contain one:

{"error": "CDP connect wss://host/cdp?token_secret=abc failed"}

That string is prose, not a URL field, so the walk leaves it alone. The fix is to redact the URL before you build the message, using the same helper directly:

let safe = redact_url_secrets(&url);
return Err(format!("CDP connect {safe} failed"));

One primitive, two entry points: the redaction walk applies it to _url fields, and you call it by hand when a URL is about to land in a string.

Surgical, and identical in four languages

Redaction replaces only the secret spans — the bytes of a secret parameter’s value, the bytes of the password. Everything else is preserved exactly: percent-encoding, parameter order, trailing slashes, case. The implementations parse with each language’s URL library to locate those spans, but never re-serialize the URL — library normalization differs between Rust, Go, Python, and TypeScript, and the whole point of AFDATA is that the same input produces the same output everywhere. A shared fixture file pins it.

A value that is not a single, whitespace-free, scheme-prefixed URL is returned unchanged. No partial guesses.

The agent rule

The pattern is the same one v0.8 set for field redaction, extended one level deeper into the value:

The result is boring in the right way. A token in a URL redacts the same way in every language, only the fields you named are touched, and an agent can explain exactly why a given *** is there.