microsoft / microsoft/markitdown
Security: delete the Content Understanding result after reading it (needed for multi-session hosted agents)
Nobody has claimed this yet.
- Dominant language
- Python
- Stars
- 186k
- Forks
- 13.7k
- Avg merge
- 1d 4h
- Merged PRs (30d)
- 49
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:
- 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-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.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.
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in converters/_cu_converter.py at convert() and trace the Content Understanding poller flow alongside the SDK's delete_result(operation_id) API. Verify how the operation ID can be recovered, then confirm that results are deleted after conversion, including failure paths, while cleanup failures only warn and conversion errors remain visible.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- azure, python
- Domain
- backend, security
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 68/100