Skip to content

Security: delete the Content Understanding result after reading it (needed for multi-session hosted agents) #2327

Description

Summary

The Content Understanding converter never deletes the server-side result after reading it. Every --use-cu conversion leaves a full extraction of the document on the Azure endpoint for ~24 hours. The SDK already ships delete_result(operation_id); MarkItDown just never calls it.

This is a ~5-line try/finally, and it's the main blocker to using MarkItDown's CU path from Copilot SDK agents hosted in Azure AI Foundry.

Related: #2326 (endpoint as operator-supplied env var). Both are about making the CU path safe to hand to automated agent callers.

Why this matters for hosted agents

A Foundry-hosted agent runs as many isolated sessions of the same agent, all sharing one CU resource and one managed identity. Session isolation is the entire security model — session A must not be able to reach session B's data. Two properties of the current behaviour punch straight through that:

  1. Results outlive the session. The session ends, its container goes away, but the extracted content sits on a resource that every other session also talks to. A shared credential means every sibling session is authorized to read it.
  2. The operation id leaks into logs. azure-core redacts the Operation-Location header, but HttpLoggingPolicy defaults to http_logging_level=logging.INFO and logs "Request URL: %r" after passing it through sanitize_url() — which redacts query-parameter values only and leaves the URL path intact. The operation id lives in the path (.../analyzerResults/{operationId}), so it is logged verbatim at INFO. Hosted platforms aggregate logs centrally; an id emitted by session A into shared logs, plus the shared identity, is a concrete path for session B to fetch session A's document.

The results endpoint has no list operation, so this isn't enumeration — it's a 24-hour window in which a leaked-or-logged id is enough. Deleting on the way out closes it, and there's no reason to keep the copy: the caller already has the Markdown.

To be precise about the boundary: Get Result is authenticated against the same credential used to Analyze, so this is not cross-tenant exposure. It's exposure within one credential boundary — which is exactly the boundary multi-session agent hosting relies on.

Evidence

Verified on markitdown 0.1.7; converters/_cu_converter.py is byte-identical to current main.

No cleanup path exists. convert() ends at the return — no try/finally, and operation_id appears nowhere in the file. A case-insensitive grep for delete|cleanup|purge|dispose across the whole installed package returns no matches at all. The SDK doesn't do it implicitly either: delete_result is defined (sync + async) but its only internal references are its own definition and its request builder. to_llm_input() is a pure formatter with no network calls.

Confirmed against the live service — driving the real MarkItDown.convert() API, then querying after it returned:

markitdown returned markdown bytes: 4644     <- conversion complete, converter done
GET  .../analyzerResults/{operationId}       -> HTTP 200, 138947 bytes
     status: Succeeded   has contents: True  <- full extraction still on the server

DELETE .../analyzerResults/{operationId}     -> HTTP 204
GET    .../analyzerResults/{operationId}     -> HTTP 404 OperationNotFound

A 4.6 KB Markdown conversion left ~139 KB of parsed document content resident on the endpoint. Per the CU data-privacy docs: "Output results are retained for up to 24 hours to support asynchronous retrieval, after which they're automatically deleted."

Callers can't clean up themselves today — the converter never surfaces the operation id, so the only way to get it is to scrape it off the wire.

Proposed change

poller = self._client.begin_analyze_binary(...)
operation_id = None
try:
    result = poller.result()
    operation_id = _operation_id_from(poller)
    text = to_llm_input(result)
    return DocumentConverterResult(markdown=text)
finally:
    if operation_id:
        try:
            self._client.delete_result(operation_id)
        except Exception:
            logger.warning(
                "Could not delete Content Understanding result; it will "
                "expire automatically within 24 hours."
            )

Two notes:

Getting the operation id. The poller has no public property for it. It's recoverable from poller.continuation_token(), which embeds the Operation-Location URL — I confirmed that round-trips (analyze → recover id → delete_result() → 204 → GET 404). But that's unofficial; cleaner options are an azure-core response hook on the header, or asking the SDK to surface operation_id on the poller. Happy to follow whichever you prefer.

Cleanup must fail soft. analyzerresults/delete is not in the built-in Cognitive Services Content Understanding Reader role, so a least-privilege caller can analyze and read but not delete. Failure must warn, not raise — hence the swallowed exception.

If some workflow needs the result to stick around, gate it behind --cu-keep-result. I'd suggest delete-by-default, since the current behaviour is silent and undiscoverable.

Happy to open a PR — it pairs naturally with #2326.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions