Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 12 additions & 8 deletions application/single_app/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -59,13 +59,6 @@
&& cp Cargo.toml Cargo.lock /onenote-source/wrapper/ \
&& cp -R src /onenote-source/wrapper/

FROM mcr.microsoft.com/azurelinux/distroless/python:3.12 AS onenote-runtime

COPY --from=onenote-builder /build/target/release/simplechat-onenote-extractor /app/native/onenote_extractor/bin/simplechat-onenote-extractor
COPY --from=onenote-builder /build/THIRD_PARTY_NOTICES.txt /usr/share/simplechat/onenote-extractor/THIRD_PARTY_NOTICES.txt
COPY --from=onenote-builder /build/licenses/ /usr/share/simplechat/onenote-extractor/licenses/
COPY --from=onenote-builder /onenote-source/ /usr/share/simplechat/onenote-extractor/source/

FROM mcr.microsoft.com/azurelinux/base/python:3.12 AS builder

ARG UID
Expand Down Expand Up @@ -242,11 +235,18 @@
;; \
esac

FROM onenote-runtime
FROM mcr.microsoft.com/azurelinux/distroless/python:3.12

Check warning on line 238 in application/single_app/Dockerfile

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

ARG UID
ARG GID

# OneNote extractor notices and corresponding source, required when redistributing the image.
# Nothing may be copied under /app before the chowned copy from the builder below creates it,

Check warning on line 244 in application/single_app/Dockerfile

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains dynamic execution, persistence, or system access marker. Recommendation%3A Do not execute changed lifecycle scripts or installers while this finding is unresolved.
# so the non-root runtime user owns its working directory.
COPY --from=onenote-builder /build/THIRD_PARTY_NOTICES.txt /usr/share/simplechat/onenote-extractor/THIRD_PARTY_NOTICES.txt
COPY --from=onenote-builder /build/licenses/ /usr/share/simplechat/onenote-extractor/licenses/
COPY --from=onenote-builder /onenote-source/ /usr/share/simplechat/onenote-extractor/source/

COPY --from=builder /etc/pki /etc/pki
COPY --from=builder /etc/ssl/certs /etc/ssl/certs
COPY --from=builder /home/nonroot /home/nonroot
Expand Down Expand Up @@ -285,6 +285,10 @@
# ever arrives from the v2uibuilder stage.
COPY --from=v2uibuilder --chown=${UID}:${GID} /single_app/static/v2 ./static/v2

# The native OneNote extractor is copied after /app already exists with runtime-user
# ownership. The binary itself stays root-owned: the app user can run it but not replace it.
COPY --from=onenote-builder /build/target/release/simplechat-onenote-extractor /app/native/onenote_extractor/bin/simplechat-onenote-extractor

# Expose port
EXPOSE 5000

Expand Down
2 changes: 1 addition & 1 deletion application/single_app/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@
EXECUTOR_TYPE = 'thread'
EXECUTOR_MAX_WORKERS = 30
SESSION_TYPE = 'filesystem'
VERSION = "0.261.232"
VERSION = "0.261.233"
IS_DEVELOPMENT = is_development_env_enabled()

# Opt-out for deployments where App Service Easy Auth is active but the platform
Expand Down
32 changes: 32 additions & 0 deletions application/single_app/functions_orchestration_rendering.py
Original file line number Diff line number Diff line change
Expand Up @@ -362,10 +362,34 @@
self._last_full = None
self._last_recheck = None
self._due_at = None
self.error_type = None
self.error_cause_type = None
self.error_errno = None

def bind(self, claim):
self.claim = claim

def record_error(self, error):
"""Keep the failure's class names and OS error number, never its message.

The output code alone can hide the cause: a local file-system PermissionError and

Check warning on line 375 in application/single_app/functions_orchestration_rendering.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
a refused conversation or result access both classify as ``output_access_denied``.

Check warning on line 376 in application/single_app/functions_orchestration_rendering.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains AI, plugin, agent, or workspace boundary marker. Recommendation%3A Check whether prompts, chat history, uploaded documents, embeddings, citations, settings, or identity can cross a new boundary.
"""
if self.error_type is not None or not isinstance(error, BaseException):
return
self.error_type = type(error).__name__
chain, current = [], error
while current is not None and len(chain) < 8 and all(current is not seen for seen in chain):
chain.append(current)
current = current.__cause__
if len(chain) > 1:
self.error_cause_type = type(chain[-1]).__name__
for item in chain:
number = getattr(item, "errno", None) if isinstance(item, OSError) else None

Check warning on line 388 in application/single_app/functions_orchestration_rendering.py

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Moderate - Changed line contains obfuscation, dynamic loading, or hidden payload marker. Recommendation%3A Confirm the changed code is not hiding behavior, decoding payloads, or bypassing normal review.
if type(number) is int:
self.error_errno = number
break

def release_stop(self):
"""Staged bytes are always committed; a later budget overrun is the owner's to report."""
self.stop = None
Expand Down Expand Up @@ -448,6 +472,12 @@
"status": "raised",
"output_code": output_failure(failure)[0] if failure is not None else None,
})
if self.error_type is not None:
facts.update({
"error_type": self.error_type,
"error_cause_type": self.error_cause_type,
"error_errno": self.error_errno,
})
return facts


Expand Down Expand Up @@ -931,6 +961,7 @@
return outcome
except Exception as exc:
failure = exc
attempt.record_error(exc)
raise
finally:
if observe is not None and attempt.claim is not None:
Expand Down Expand Up @@ -1020,6 +1051,7 @@
except Exception as exc:
if observing:
raise
attempt.record_error(exc)
return self._record_failure(
output_id, exc, claim, skip_commit_reconciliation=skip_commit_reconciliation,
)
Expand Down
3 changes: 2 additions & 1 deletion application/single_app/functions_simplechat_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,7 @@
)
from functions_settings import get_settings, is_user_workflows_enabled_for_user
from functions_saved_analysis import analysis_artifact_metadata, authorize_analysis_artifact
from functions_temp_files import scratch_file_dir
from utils_cache import invalidate_group_search_cache, invalidate_personal_search_cache


Expand Down Expand Up @@ -3404,7 +3405,7 @@ def open_generated_chat_artifact_stream(artifact, *, check=None):
raise RuntimeError("Blob storage client not available")
blob_client = blob_service_client.get_blob_client(container=artifact["blob_container"], blob=artifact["blob_path"])
try:
with tempfile.TemporaryFile(mode="w+b", dir=".") as content:
with tempfile.TemporaryFile(mode="w+b", dir=scratch_file_dir()) as content:
digest, size = hashlib.sha256(), 0
if check is not None:
check()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
GeneratedFileExportStream,
)
from functions_generated_export_registry import resolve_generated_file_export_format
from functions_temp_files import scratch_file_dir


_CHUNK_CHARACTERS = 16384
Expand Down Expand Up @@ -441,7 +442,9 @@ def _render_generated_file_source(source, request, *, max_output_bytes, check=No
checks = _SourceChecks(source, check)
checks.run()
with ExitStack() as resources:
stream = resources.enter_context(ClosingExportResource(tempfile.TemporaryFile(mode='w+b', dir='.')))
stream = resources.enter_context(ClosingExportResource(
tempfile.TemporaryFile(mode='w+b', dir=scratch_file_dir()),
))
try:
writer = _OutputWriter(stream, max_output_bytes, checks)
count = _RENDERERS[entry.renderer_id](writer, source, request, limits, checks)
Expand Down
22 changes: 22 additions & 0 deletions application/single_app/functions_temp_files.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# functions_temp_files.py
"""Writable scratch locations for short-lived server-side files.

Import-independent on purpose: renderers and download streams use it without
pulling in configuration or Azure clients.
"""

import os


SC_TEMP_FILES_DIR = '/sc-temp-files'


def scratch_file_dir():
"""Return the container's dedicated scratch directory, or None for the platform default.

Never the process working directory. The container runs as a non-root user, and
the application code directory is not guaranteed to be writable by that user.
"""
if os.path.isdir(SC_TEMP_FILES_DIR) and os.access(SC_TEMP_FILES_DIR, os.W_OK | os.X_OK):
return SC_TEMP_FILES_DIR
return None
12 changes: 10 additions & 2 deletions docs/explanation/features/ONENOTE_INGESTION.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,8 +145,16 @@
## Build and file layout

The Dockerfile has an `onenote-builder` stage with a pinned Rust 1.85.1 image.
The application inherits the `onenote-runtime` stage, containing the native
executable on the same Azure Linux distroless Python 3.12 base.
The final application stage builds directly from the Azure Linux distroless

Check warning on line 148 in docs/explanation/features/ONENOTE_INGESTION.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
Python 3.12 image and copies the extractor's third-party notices and
corresponding source from `onenote-builder`. It copies the native executable to
`/app/native/onenote_extractor/bin/` after `/app` already exists with
runtime-user ownership. The final stage must not inherit an intermediate stage
or copy anything under `/app` before that point: a root-owned `/app` stops the
non-root application from writing to its working directory (see
`docs/explanation/fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md`). The

Check warning on line 155 in docs/explanation/features/ONENOTE_INGESTION.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.
executable stays root-owned, so the application user can run it but not
replace it.
The React branch retains its independent `v2uibuilder` stage and copies its
fresh bundle into that final application image. Publishing the branch is not
a deployment; an existing test app needs a rebuild from this branch before it
Expand Down
158 changes: 158 additions & 0 deletions docs/explanation/fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
# Orchestration File Render Permission Fix

Check warning on line 1 in docs/explanation/fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains security control, sanitization, or audit marker. Recommendation%3A Confirm the change does not weaken auth, CSRF, CSP, XSS defenses, settings sanitization, redaction, audit logging, or tests.

**Version: 0.261.233**

Fixed in version: **0.261.233**, recorded in
`application/single_app/config.py`.

Issue: [#1623](https://github.com/microsoft/simplechat/issues/1623). This affects

Check warning on line 8 in docs/explanation/fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
the React V2 branch (`paullizer-react-v2-ui`) only. The causal commits are not on
`main` or `Development`.

## Issue

Chat orchestration could not create downloadable files. A request such as
"create a csv showing the states and their capitals" planned correctly, and the
compose step that prepared the rows completed. The file step then failed, and
the chat showed:

- `us_states_and_capitals.csv: could not be created.`
- `This operation could not complete.`

Production telemetry recorded the render attempt as `output_access_denied`

Check warning on line 22 in docs/explanation/fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md

View workflow job for this annotation

GitHub Actions / malicious-pr-security-review

Important - Changed line contains external connection or remote asset marker. Recommendation%3A Review whether changed code can send prompts, files, credentials, cookies, settings, logs, or user data to a new sink.
after about 260 ms of rendering. At the time, that code meant source access
could not be confirmed, but the source was general knowledge with no documents
involved. Since 0.261.232 the same code means access to the file's
conversation could not be confirmed.

## Root cause

Two changes combined to cause the failure. Neither caused it on its own.

1. **Container image.** The OneNote native extractor work (2026-09-26) made the
final image stage `FROM onenote-runtime`. That stage copied the extractor
binary to `/app/native/onenote_extractor/bin/`, which implicitly created
`/app` owned by `root:root` with mode `0755`. The later
`COPY --from=builder --chown=${UID}:${GID} /app /app` copies only the
directory's contents, so `/app` itself stayed root-owned. The application
runs as UID `65532` with `/app` as its working directory.
2. **Scratch-file location.** The shared structured file renderer
(`functions_structured_file_renderers.py`) and the verified generated-file
download stream (`open_generated_chat_artifact_stream` in
`functions_simplechat_operations.py`) created their scratch files with
`tempfile.TemporaryFile(dir='.')`, the process working directory.

Creating a scratch file in `/app` therefore raised
`PermissionError: [Errno 13]`. `output_failure()` classifies every
`PermissionError` as `output_access_denied`, and the original exception was not
logged, so the cause was hidden.

Inspection of the deployed image layers confirmed the regression:

| Image | `/app` owner | CSV render |
|---|---|---|
| `20260926-113853` (before the OneNote stage change) | `65532:65532` | Completed |
| `20260930-185829`, `20261002-145750` (after) | `0:0`, mode `0755` | `output_access_denied` |

### Affected behavior

- Every orchestration file in the structured formats (CSV, JSON, XML, YAML,
TXT, and Markdown) failed to render.
- Office formats (DOCX, XLSX, PPTX, PDF) render in memory, but downloading any
orchestration-generated file streams it through the same working-directory
scratch file, so those downloads would fail as well.

## Technical details

### Files modified

| File | Change |
|---|---|
| `application/single_app/functions_temp_files.py` | New import-independent `scratch_file_dir()` helper. |
| `application/single_app/functions_structured_file_renderers.py` | The renderer's scratch file uses `scratch_file_dir()` instead of `dir='.'`. |
| `application/single_app/functions_simplechat_operations.py` | `open_generated_chat_artifact_stream` uses `scratch_file_dir()` instead of `dir="."`. |
| `application/single_app/functions_orchestration_rendering.py` | A failed render attempt records its error class names and OS error number in its diagnostics. |
| `application/single_app/Dockerfile` | The `onenote-runtime` stage is removed. The final stage builds from the pinned distroless image and copies the extractor's notices and source directly. The extractor binary is copied after the app code. |
| `docs/explanation/features/ONENOTE_INGESTION.md` | Build layout updated for the new copy location. |
| `docs/reference/logging-tags.md` | New render-attempt diagnostic fields documented. |
| `functional_tests/test_orchestration_file_render_scratch_dir.py` | New regression tests. |
| `functional_tests/test_generated_file_structured_renderers.py`, `functional_tests/test_orchestration_output_lifecycle.py` | Their stream-tracking doubles required `dir='.'`, which pinned the defect. They now require the `scratch_file_dir()` location. |
| `functional_tests/test_workflow_saved_output_artifacts.py` | Supplies `scratch_file_dir` to the download function it loads from source. |
| `functional_tests/test_v2_onenote_uploads.py` | The image-wiring test required the final stage to inherit `onenote-runtime`. It now requires the distroless base and the extractor binary, notices, licenses, and source in the final stage. |

### Scratch directory

`scratch_file_dir()` returns `/sc-temp-files` when that directory exists and the
process can write to it. The image creates `/sc-temp-files` for the runtime
user, and document uploads already use it. Otherwise the function returns
`None`, which selects the platform temp directory (`/tmp` in the image, `%TEMP%`
on Windows development machines). It never returns the working directory.

### Image layout

The `onenote-runtime` stage is removed. The final stage builds directly from
`mcr.microsoft.com/azurelinux/distroless/python:3.12`, as it did before
2026-09-26. It copies the extractor's notices and corresponding source from
`onenote-builder` to `/usr/share/simplechat/onenote-extractor/`, so the
redistribution notices are unchanged. The chowned copy from the builder stage
then creates `/app` owned by the runtime user. The extractor binary is copied
afterward to the path `functions_onenote.py` expects. The binary remains
root-owned: the application user can run it but cannot replace it.

Removing the stage also clears a false positive in the Malicious PR Security
Review. That check inspects the whole Dockerfile whenever a pull request
changes it. It requires each `FROM` to name an image with a tag or digest, so it
flagged `FROM onenote-runtime`, a build-stage name. The final `FROM` now names
the pinned image, and the check itself is unchanged.

### Render diagnostics

The `[ORCHESTRATION_EXECUTOR] A file render attempt finished.` event now also
includes, for a failed attempt:

- `error_type`: the class name of the exception that failed the attempt.
- `error_cause_type`: the class name at the end of its explicit `__cause__`
chain, when the error was wrapped.
- `error_errno`: the first OS error number in that chain, when there is one.

Exception messages, file paths, and identifiers are never recorded. A
successful attempt does not include these fields. The user-facing
classification is unchanged. A file-system `PermissionError` still reports
`output_access_denied`, the same code as an access refusal such as losing
access to the file's conversation. Only the file-system error logs
`error_type=PermissionError` and `error_errno=13`, so the two can be told
apart.

## Validation

### Tests

`functional_tests/test_orchestration_file_render_scratch_dir.py`:

- `scratch_file_dir()` returns the dedicated directory only when it exists and
is writable.
- No application `tempfile` call passes `dir='.'`, `dir=''`, or
`dir=os.getcwd()`.
- A CSV file renders, commits, and downloads through the real renderer and the
real download stream while any scratch file aimed at the working directory
fails with `EACCES`, as it does in the container. This test fails on the
previous code with `output_access_denied`.
- A failed render logs `PermissionError` and errno `13` without the message or
path. A wrapped failure logs its outer class, root cause class, and errno.
- The Dockerfile's final stage builds from a pinned image rather than another
build stage, creates `/app` only through the runtime user's chowned copy, and
adds the extractor afterward.

Related suites that pass with the change: `test_orchestration_render_check_cadence.py`,
`test_orchestration_output_lifecycle.py`, `test_generated_file_structured_renderers.py`,
`test_workflow_saved_output_artifacts.py`, `test_orchestration_artifact_publication.py`,
`test_document_provenance_publication.py`, and `test_v2_onenote_uploads.py`.

### Deployment

Rebuild and redeploy the image. Each change would fix the failure on its own:
the code no longer writes scratch files to the working directory, and the
rebuilt image gives the runtime user ownership of `/app` again. Both are kept
so a later image-layout change cannot silently break file generation. After
deploying, a failed file render can be diagnosed from the `error_type` and
`error_errno` fields of the render-attempt event.
11 changes: 11 additions & 0 deletions docs/explanation/release_notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

For feature-focused and fix-focused drill-downs by version, see [Features by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/features) and [Fixes by Version](https://github.com/microsoft/simplechat/tree/main/docs/explanation/fixes).

### **(v0.261.233)**

#### Bug Fixes

* **Orchestration File Generation Fix**
* Chat orchestration could no longer create downloadable files. A request such as "create a csv showing the states and their capitals" planned correctly and prepared its rows, then the file step failed with "This file could not be created." Every CSV, JSON, XML, YAML, TXT and Markdown file failed this way. Word and PDF files rendered, but downloading any orchestration-generated file used the same failing step, so those downloads would have failed too.
* Two changes interacted to cause this. Since the OneNote extractor was added to the container image, `/app` has been owned by root, while the app runs as a non-root user with `/app` as its working directory. The file renderer and the verified download stream created their scratch files in the working directory, so the operating system refused them. The refusal was reported as `output_access_denied`, a code meant for access refusals, and the real cause wasn't logged.
* Scratch files now go to `/sc-temp-files` when it's writable, and otherwise to the platform temp directory, never to the working directory. The image creates `/app` for the runtime user again. The OneNote extractor binary is added after that and stays root-owned.
* For a failed attempt, the `[ORCHESTRATION_EXECUTOR] A file render attempt finished.` event now also records `sc_error_type`, `sc_error_cause_type` and `sc_error_errno`, so a file-system error can be told apart from an access refusal. Messages and paths aren't logged.
* (Ref: #1623, `functions_temp_files.py`, `functions_structured_file_renderers.py`, `functions_simplechat_operations.py`, `functions_orchestration_rendering.py`, `Dockerfile`, [Orchestration File Render Permission Fix](fixes/ORCHESTRATION_FILE_RENDER_PERMISSION_FIX.md))

### **(v0.261.232)**

#### Breaking Changes
Expand Down
Loading
Loading