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:
- 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.
- 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.
Summary
The Content Understanding converter never deletes the server-side result after reading it. Every
--use-cuconversion leaves a full extraction of the document on the Azure endpoint for ~24 hours. The SDK already shipsdelete_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:
Operation-Locationheader, butHttpLoggingPolicydefaults tohttp_logging_level=logging.INFOand logs"Request URL: %r"after passing it throughsanitize_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.pyis byte-identical to currentmain.No cleanup path exists.
convert()ends at the return — notry/finally, andoperation_idappears nowhere in the file. A case-insensitive grep fordelete|cleanup|purge|disposeacross the whole installed package returns no matches at all. The SDK doesn't do it implicitly either:delete_resultis 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: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
Two notes:
Getting the operation id. The poller has no public property for it. It's recoverable from
poller.continuation_token(), which embeds theOperation-LocationURL — 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 surfaceoperation_idon the poller. Happy to follow whichever you prefer.Cleanup must fail soft.
analyzerresults/deleteis not in the built-inCognitive Services Content Understanding Readerrole, 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.