Skip to content
Open
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
4 changes: 3 additions & 1 deletion docs/system-specs/modules/learn-cron-dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -5138,7 +5138,9 @@ always states the limit, a failed config read is reported through an inline
`ErrorNotice` saying uploads still work and the server checks each file, and
the page then shows no "Max N MB per file" figure (otherwise it interpolates
`knowledge_upload_max_mb`). It is separate from `files._MAX_UPLOAD_BYTES` (50 MB), which still bounds
the in-memory read paths (file-raw, file-download, office and sheet preview).
the in-memory read paths (file-raw, office and sheet preview). `GET /api/file-download` has no size
cap: a file up to that size is read whole and scanned, a larger one streams unscanned once the owner
confirms; see [security.md](security.md) § Dashboard File Download.
Every stored name receives a UUID prefix, and the handler attributes accepted and
rejected requests through SEL. Binary formats with reliable signatures are
validated by content before publication under their destination path.
Expand Down
37 changes: 37 additions & 0 deletions docs/system-specs/modules/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -3683,6 +3683,43 @@ All outbox downloads include:
host provides it, so Windows cannot translate embedded LF bytes to CRLF and
corrupt archives or media between validation and the restricted write.

#### Dashboard File Download (`GET /api/file-download`, `file_api/transfer.py`)

The file viewer's and file tree's Download serve a host file as `attachment`
with `X-Content-Type-Options: nosniff`. Every request passes the owner-only gate,
`validate_tool_args`, dashboard path validation, the sensitive-path filter and
the `O_NOFOLLOW` open, at any size: a `.env` or credential path is refused
whatever the file weighs and whatever the query says.

- **Up to `_DOWNLOAD_SCAN_MAX_BYTES` (50 MB, the upload cap):** the file is read
whole from the open descriptor and scanned (`redact()` on UTF-8 text,
`binary_content_is_flagged` on other bytes, `wide_content_is_flagged` on both).
A hit refuses 400 `content_redacted` unless the owner's `owner_dashboard`
file-delivery grant releases it, with the refusal or consent audited. A file
that grows past the threshold between the fstat and the read is refused 409
`file_changed`.
- **Over the threshold: delivered UNSCANNED, by the maintainer's decision.** The
scan needs the whole file in memory, so a multi-GB file would exhaust the
gateway; and the route is owner-only, so the reader is the person whose
machine holds the file. Without `confirm_unscanned=1` the route answers 409
`large_file_unscanned` with `size` and `limit` and sends nothing; the dashboard
shows a "Too large to scan" dialog naming the file and its size, and only
**Download anyway** retries with the flag. With the flag the file streams in
`_STREAM_CHUNK_BYTES` chunks with `Content-Length` set to its fstat size.
Each chunk is read on the bounded transfer pool, never the shared default
executor, within `_DOWNLOAD_CHUNK_STALL_SECS`. A read that stalls (a hung
mount) or a pool that refuses it is audited and drops the connection at once;
the file's close then runs on a thread of its own, because the abandoned read
still holds the file's buffer lock and a close on the pool would wait on it.
SEL records the delivery with `error="unscanned_large_file"`, so the audit
shows it was not scanned.
- **Small or large is decided by the fstat of the one descriptor every byte is
read from**, so a different file cannot be swapped in between the size check
and the send. A file that grows while it streams is cut at the fstat size; one
that shrinks drops the connection, so the client sees a failed download.
- The flag changes nothing for a file at or under the threshold: it is scanned
exactly as without it.

#### Slack Delivery Audience — Strict Caller-Identity Classification (`file_send`)

`file_send` may additionally upload the file to Slack. The Slack **audience**
Expand Down
79 changes: 49 additions & 30 deletions src/kiro_crew/dashboard/file_api/pinned_io.py
Original file line number Diff line number Diff line change
Expand Up @@ -292,6 +292,54 @@ class _OpenedFile(NamedTuple):
data: bytes


def _open_refusal(denied: _OpenDenied, *, tool_name: str) -> _OpenRefusal:
"""Audit one :func:`_open_checked_file` refusal and build its response.

The one mapping of the open-and-check codes onto the whole-read envelope's
SEL vocabulary and response bodies, shared by :func:`_open_checked` and by
the streamed ``/api/file-download``, so the two cannot drift apart.
"""

def _log(outcome: str, res: str, error: str = "") -> None:
kw = {"error": error} if error else {}
_sel().log_tool_invocation(
session_key="dashboard",
tool_name=tool_name,
outcome=outcome,
resources=res,
**kw,
)

code, res = denied.code, denied.path
if code == "invalid_path":
_log("denied", res)
return _OpenRefusal(web.json_response({"error": "invalid or forbidden path"}, status=400))
if code == "sensitive_path":
_log("denied", res, "sensitive_path")
return _OpenRefusal(web.json_response({"error": "sensitive path blocked"}, status=403))
if code == "not_found":
_log("not_found", res)
return _OpenRefusal(web.json_response({"error": "not found"}, status=404))
if code == "symlink_refused":
_log("denied", res, "symlink_rejected")
return _OpenRefusal(web.json_response({"error": "symlinks not allowed"}, status=403))
if code == "file_too_large":
# Reachable only through a caller that passes fstat_cap; mapped so
# a policy refusal can never masquerade as the 500 below.
_log("denied", res, "file_too_large")
return _OpenRefusal(
web.json_response({"error": "file too large", "code": "file_too_large"}, status=413)
)
# read_failed: the residual code. (The whole-read envelope's own size
# guard is its bounded read, because an fstat pre-check races a
# concurrent writer while reading at most cap+1 bytes bounds memory
# unconditionally -- the same shape as _load_sheet_payload's guard.)
_log("failure", res)
return _OpenRefusal(
web.json_response({"error": "cannot read file", "code": "read_failed"}, status=500)
)


def _open_checked(
raw_path: str,
*,
Expand Down Expand Up @@ -338,36 +386,7 @@ def _log(outcome: str, res: str, error: str = "") -> None:

checked = _open_checked_file(raw_path, tool_name=tool_name)
if isinstance(checked, _OpenDenied):
code, res = checked.code, checked.path
if code == "invalid_path":
_log("denied", res)
return _OpenRefusal(
web.json_response({"error": "invalid or forbidden path"}, status=400)
)
if code == "sensitive_path":
_log("denied", res, "sensitive_path")
return _OpenRefusal(web.json_response({"error": "sensitive path blocked"}, status=403))
if code == "not_found":
_log("not_found", res)
return _OpenRefusal(web.json_response({"error": "not found"}, status=404))
if code == "symlink_refused":
_log("denied", res, "symlink_rejected")
return _OpenRefusal(web.json_response({"error": "symlinks not allowed"}, status=403))
if code == "file_too_large":
# Reachable only through a caller that passes fstat_cap; mapped so
# a policy refusal can never masquerade as the 500 below.
_log("denied", res, "file_too_large")
return _OpenRefusal(
web.json_response({"error": "file too large", "code": "file_too_large"}, status=413)
)
# read_failed: the residual code. (This envelope's own size guard is
# the bounded read below, because an fstat pre-check races a
# concurrent writer while reading at most cap+1 bytes bounds memory
# unconditionally -- the same shape as _load_sheet_payload's guard.)
_log("failure", res)
return _OpenRefusal(
web.json_response({"error": "cannot read file", "code": "read_failed"}, status=500)
)
return _open_refusal(checked, tool_name=tool_name)

path = checked.path
try:
Expand Down
Loading
Loading