Skip to content
Closed
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
1 change: 1 addition & 0 deletions docs/guides/windows-install.md
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ while the other 503s. Concretely:
| Feature | Status on Windows |
|---------|-------------------|
| Core gateway / chat / dashboard | works — a source install with a built `website/dist` is linked into `src/kiro_crew/static/dist` at gateway start via a **directory junction** (`platform_compat.symlink_or_junction`), which needs no privilege; a symlink there would need `SeCreateSymbolicLinkPrivilege` and would leave a non-elevated install serving the "not built" page |
| Theme-pack install, detail, assets, overlays, topbars, and removal | works — opened pack files are contained with `GetFinalPathNameByHandleW`; descriptor resolution fails closed instead of trusting a pathname-only check |
| LLM cron jobs (the `message` kind) | works |
| Script cron jobs | need the `agent.sandbox_allow_unsandboxed_exec` opt-in above — they run through `wrap_argv`, which fail-closes where no OS sandbox backend exists. Without it the job fails with a message naming that setting (it no longer raises an uncaught error) |
| Command cron jobs (`sh -c "…"`) | not supported on Windows — the stored command is vetted under POSIX-sh semantics, and Windows ships no shell whose language matches: cmd.exe is not POSIX at all, and Git-for-Windows's `sh.exe` is bash and performs brace expansion that hides `cat ~/.a{w,w}s/credentials` from the vet. The job fails-closed with an explanation. Use a **script cron** or an LLM `message` cron on this platform |
Expand Down
5 changes: 5 additions & 0 deletions docs/system-specs/common/testing-conventions.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,11 @@ an unconditional skip drops the whole assertion on Windows. Reach for a skip onl
where the *link kind itself* is the subject (a file symlink's `lstat` mode bits,
say), and then still pair it with a Windows counterpart.

The shared symlink capability probe skips only when Windows reports
`ERROR_PRIVILEGE_NOT_HELD` (WinError 1314), or when the runtime has no symlink
API. Other filesystem errors propagate so a broken fixture cannot silently
remove the containment assertion from the test run.

### Patch the defining module, not a re-export

`monkeypatch.setattr`/`patch` rebind a NAME in one module namespace. Code
Expand Down
12 changes: 11 additions & 1 deletion docs/system-specs/modules/themes.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,11 @@ runtime scoper still removes the pin, so the preference is protected either way.

1. **Source** — a local directory (moved/copied) or an https `github.com` repo
shallow-cloned server-side (`_clone_github`, `--depth 1`, 30s timeout, host
allowlist).
allowlist). The clone spawns through the sandbox chokepoint, which fails
**closed** where no OS sandbox backend exists: that refusal answers `503`
with `code: "theme_install_sandbox_unavailable"`, never an unsandboxed
retry — the URL is user-influenced and `git clone` executes remote content.
A **local** source spawns nothing, so it stays available on such a host.
2. **Stage** — the source is copied into a private staging snapshot
(`.install-staging-<token>`) via a per-file, symlink-rejecting,
byte-bounded loop (`_copy_installed_theme`). The source dir remains
Expand Down Expand Up @@ -115,6 +119,12 @@ predate this subsystem and remain the color-theme surface.)
- **Locked CSP** — overlay/topbar responses carry a fixed
`Content-Security-Policy` including a `sandbox` directive; asset responses
carry `X-Content-Type-Options: nosniff` and a content-type allowlist.
- **Descriptor-pinned containment** — pack install and serving resolve the
opened file descriptor before trusting bytes: `/proc/self/fd` on Linux,
`fcntl.F_GETPATH` on macOS, and `GetFinalPathNameByHandleW` on Windows. The
resolved path must remain inside the pack root; an unavailable or failed
resolution rejects the read rather than falling back to a pathname-only
check.
- **postMessage allowlist** — the parent (`ThemeExperienceLayer.tsx`) accepts
only `theme:resize`, `theme:sound`, `theme:visibility`, and `theme:state`
messages from a pack iframe; all others are dropped.
Expand Down
6 changes: 3 additions & 3 deletions error-code-baseline.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
{
"_comment": "Error responses without a machine-readable `code`, per file. Generated - regenerate with `python test/test_error_code_contract.py --update`. This is both the CI ratchet and the Track B worklist: drive `missing_code` to zero, one PR per file or per directory, moving each frontend consumer in the same PR. Never raise a number to make CI pass. See test/test_error_code_contract.py for what each bucket means and which false negatives it accepts.",
"_totals": {
"missing_code": 1399,
"missing_code": 1398,
"opaque_body": 18,
"dynamic_status": 43
},
"_compliant": 758,
"_compliant": 765,
"files": {
"apps/builtins/auto_research/handlers.py": {
"dynamic_status": 1,
Expand Down Expand Up @@ -166,7 +166,7 @@
},
"dashboard/handlers/themes.py": {
"dynamic_status": 4,
"missing_code": 24
"missing_code": 23
},
"dashboard/handlers/updates.py": {
"missing_code": 9
Expand Down
59 changes: 21 additions & 38 deletions src/kiro_crew/dashboard/handlers/themes.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,31 +61,18 @@
)
from kiro_crew.executors import discovery_executor
from kiro_crew.hooks import safe_read_file_bytes_nolink
from kiro_crew.sandbox import resource_limit_preexec, sandboxed_spawn_argv
from kiro_crew.sandbox import (
SandboxUnavailableError,
resource_limit_preexec,
sandboxed_spawn_argv,
)
from kiro_crew.security import (
is_sensitive_path,
redact_credentials,
redact_exfiltration_urls,
)

# Theme install/serve traverses the O_NOFOLLOW + fd-real-path chokepoint in
# hooks (safe_read_file_bytes_nolink), which has no Windows implementation
# (_fd_real_path returns None there -> fail-closed on every read). Rather than
# fail opaquely, gate the pack routes with an honest 501 on Windows.
# Tracked: kirodotdev/KiroCrew#311. The editor custom-record (<slug>.json) CRUD
# paths never touch that chokepoint, so they are intentionally NOT gated.
_THEMES_WIN_UNSUPPORTED = os.name == "nt"


def _win_unsupported_response() -> web.Response:
"""501 for pack routes that rely on the POSIX-only nolink chokepoint."""
return web.json_response(
{
"error": "theme packs are not yet supported on Windows "
"(tracked: kirodotdev/KiroCrew#311)"
},
status=501,
)
_THEME_GIT_SANDBOX_UNAVAILABLE = "theme install sandbox is unavailable on this server"


def _list_themes_sync() -> list[dict[str, Any]]:
Expand Down Expand Up @@ -240,9 +227,15 @@ def _clone_github(url: str, dest: Path) -> str | None:
# content, so route through the sandbox chokepoint (OS filesystem isolation
# + credential-scrubbed env) and apply the fork-bomb/resource ceiling via
# preexec_fn — same discipline as git_coord._git.
argv, env, cleanup = sandboxed_spawn_argv(
["git", "clone", "--depth", "1", "--quiet", "--", url, str(dest)]
)
try:
argv, env, cleanup = sandboxed_spawn_argv(
["git", "clone", "--depth", "1", "--quiet", "--", url, str(dest)]
)
except SandboxUnavailableError:
# Translate the typed sandbox refusal at this boundary. In particular,
# Windows has no process-sandbox backend, but local theme installs and
# every descriptor-contained read route remain supported there.
return _THEME_GIT_SANDBOX_UNAVAILABLE
try:
proc = subprocess.run(
argv,
Expand Down Expand Up @@ -404,7 +397,8 @@ def _do_install(stype: Any, source: dict[str, Any]) -> tuple[dict[str, Any] | No
else:
return None, "source.type must be 'local' or 'github'", 400
if err or src is None:
return None, err or "invalid source", 400
status = 503 if err == _THEME_GIT_SANDBOX_UNAVAILABLE else 400
return None, err or "invalid source", status

# ── Stage-first (TOCTOU class fix) ──
# The source dir stays writable by its owner throughout, so a
Expand Down Expand Up @@ -527,9 +521,6 @@ async def api_themes_install(request: web.Request) -> web.Response:
Fetch/move -> validate (data + structure) -> register as
``_themes_dir()/<slug>/``.
"""
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()

# Governance admission gate: installing a pack ingests third-party content
# (local move or server-side git clone) and serves sandboxed JS into the
# dashboard, so an enterprise POLICY must be able to ban it wholesale
Expand Down Expand Up @@ -575,7 +566,10 @@ async def api_themes_install(request: web.Request) -> web.Response:
discovery_executor(), _do_install, stype, source
)
if err or theme is None:
return web.json_response({"error": err or "install failed"}, status=status)
payload = {"error": err or "install failed"}
if err == _THEME_GIT_SANDBOX_UNAVAILABLE:
payload["code"] = "theme_install_sandbox_unavailable"
return web.json_response(payload, status=status)

return web.json_response({"ok": True, "slug": theme["slug"], "theme": theme})

Expand All @@ -598,9 +592,6 @@ async def api_theme_detail(request: web.Request) -> web.Response:
)
return web.json_response({"ok": True})
if dir_target.is_dir():
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()

# Recursive delete of a many-file theme dir is blocking; run off-loop.
# Acquire the per-slug install lock (same key _do_install stages/swaps
# under) so we never rmtree mid-reinstall and race its stage→rename;
Expand Down Expand Up @@ -676,8 +667,6 @@ def _update_locked() -> dict:
return web.json_response({"error": "failed to read theme"}, status=500)
return web.json_response(data)
if dir_target.is_dir():
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()
summary, err = await loop.run_in_executor(
discovery_executor(), _validate_theme_dir, dir_target
)
Expand Down Expand Up @@ -737,8 +726,6 @@ def _theme_html_response(text: str) -> web.Response:

async def api_theme_asset(request: web.Request) -> web.Response:
"""GET /api/theme/{slug}/assets/{path} — serve a static theme asset."""
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()
target, err = _resolve_theme_asset(
request.match_info["slug"], request.match_info.get("path", "")
)
Expand Down Expand Up @@ -767,8 +754,6 @@ async def api_theme_asset(request: web.Request) -> web.Response:

async def api_theme_overlay(request: web.Request) -> web.Response:
"""GET /api/theme/{slug}/overlay/{id} — serve overlay HTML (id = file stem)."""
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()
oid = request.match_info["id"].lower()
if not oid or _safe_theme_slug(oid) != oid:
return web.json_response({"error": "invalid overlay id"}, status=400)
Expand All @@ -788,8 +773,6 @@ async def api_theme_overlay(request: web.Request) -> web.Response:

async def api_theme_topbar(request: web.Request) -> web.Response:
"""GET /api/theme/{slug}/topbar/{mode} — serve topbar HTML (mode dark|light)."""
if _THEMES_WIN_UNSUPPORTED:
return _win_unsupported_response()
mode = request.match_info["mode"]
if mode not in ("dark", "light"):
return web.json_response({"error": "mode must be dark or light"}, status=400)
Expand Down
5 changes: 3 additions & 2 deletions src/kiro_crew/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -1798,8 +1798,9 @@ def safe_read_file_bytes_nolink(
``st_nlink > 1`` or a non-regular file type is rejected.

When ``within_root`` is given, the OPENED descriptor's real path
(via ``/proc/self/fd`` on Linux, ``fcntl.F_GETPATH`` on macOS) must resolve
inside that root and must not be sensitive. ``O_NOFOLLOW`` only guards the
(via ``/proc/self/fd`` on Linux, ``fcntl.F_GETPATH`` on macOS, or
``GetFinalPathNameByHandleW`` on Windows) must resolve inside that root and
must not be sensitive. ``O_NOFOLLOW`` only guards the
FINAL path component — a nested directory swapped for a symlink between
the tree walk and the open would silently escape the approved tree. The
fd-path check is pinned to the inode actually opened, so no check-to-use
Expand Down
Loading
Loading