Skip to content

fix(frontend): let both static/dist readers see the link this module creates - #9062

Open
leonlaiyc wants to merge 2 commits into
kirodotdev:mainfrom
leonlaiyc:fix/static-dist-junction-readers
Open

fix(frontend): let both static/dist readers see the link this module creates#9062
leonlaiyc wants to merge 2 commits into
kirodotdev:mainfrom
leonlaiyc:fix/static-dist-junction-readers

Conversation

@leonlaiyc

Copy link
Copy Markdown
Contributor

Problem / Motivation

frontend._ensure_tree_dist publishes src/kiro_crew/static/dist itself:

platform_compat.symlink_or_junction(str(candidate), str(tree_dist))   # frontend.py:235

symlink_or_junction tries os.symlink first and falls back to a directory
junction on Windows, because a directory symlink there needs
SeCreateSymbolicLinkPrivilege that an ordinary unelevated process does not hold.
So on a normal Windows dev box, KiroCrew's own writer puts a junction at that
path
— this is not a planted or adversarial shape, it is the product's default
output. Measured on an unelevated Windows shell:

after symlink_or_junction:  is_link_or_junction=True  is_symlink=False  is_dir=True

Two readers of that same path ask is_symlink(), which is False for a junction.

1. pod.provision.build_dist — the link check runs ahead of
is_dir()/is_file() on purpose, so a dangling link is still replaced (the
same ordering frontend._stage_dist uses, and for the same reason). A dangling
junction answers False to all three:

dangling junction:  is_symlink=False  is_file=False  is_dir=False  exists=False
                    is_link_or_junction=True

Every branch is skipped and shutil.copytree(src_dist, dst) lands on a directory
entry that still exists. Measured: FileExistsError: [WinError 183], and nothing
up the provisioning chain handles it — provision() and its pod callers surface
a traceback instead of the FATAL: line every other failure there produces.

2. frontend._discard_path — its own docstring already explains the mechanism:

shutil.rmtree refuses a symlink even though is_dir() follows it and
returns True.

…and then tests is_symlink(), 170 lines below the symlink_or_junction call that
creates the junction. A live junction therefore reaches the rmtree branch,
whose refusal ignore_errors=True swallows; a dangling one matches no branch at
all. Either way the entry survives.

Why it matters

build_dist is the crash: pod up / provision(build=True) on a checkout whose
static/dist link has gone dangling — the linked tree was cleaned, or the worktree
it pointed into was removed — dies with an unhandled FileExistsError rather than
restaging. The identical situation with a symlink recovers cleanly, so this is a
Windows-only failure of an already-implemented recovery path.

_discard_path is the leak, and it has a caller-visible tail: _stage_dist calls
_discard_path(backup) to clear .dist.previous.<pid> and then os.replaces the
served bundle onto that name. A surviving entry makes that replace fail, which the
except OSError turns into "Could not stage static/dist" — a refused publication
whose cause is an entry that was supposed to have been reclaimed. The sweep over
.dist.previous.* at the end has the same blind spot, so the entries accumulate.

Graded honestly: no data loss and no containment escape. rmtree refuses a
junction rather than deleting through it, so nothing behind the link is ever
destroyed. What breaks is availability of the provisioning/publication path.

What changed (motivation → approach → change)

Symptom: a junction at static/dist crashes build_dist and is never reclaimed
by _discard_path.
Root cause: both readers spell "is this a link?" as is_symlink(), which does not
see the junction this very module writes.
Change: both route through the repo's canonical pair.

if platform_compat.is_link_or_junction(dst):
    platform_compat.unlink_link_or_junction(dst)
elif dst.is_file():
    dst.unlink()
elif dst.is_dir():
    shutil.rmtree(dst)

The symlink path is behaviour-preserving: unlink_link_or_junction calls
os.unlink for a symlink — exactly what both sites already did — and rmdir only
for a junction. The link branch stays first so the dangling case keeps working,
which is why it was written first in the original.

The unlink half matters as much as the predicate. The obvious "fix" once a
junction is detected — reach for shutil.rmtree — would delete through it and
destroy the linked tree. Detaching with rmdir is what keeps the target intact, and
that property is asserted rather than asserted-in-prose.

Both files already import platform_compat; no import path grew. Nothing else in
either module is touched.

Why both sites in one PR: they are two readers of a single link, written by a
single call, and the fix is the same two lines. Fixing one would leave the other
looking at the same path with the same blind predicate.

Tests

test/test_pod.py (TestProvisionBuildPaths) and
test/test_frontend_edition_build.py, 5 tests:

Test Locks in
test_build_dist_restages_over_a_dangling_dist_link a dangling link is replaced and the fresh bundle lands — the FileExistsError case
test_build_dist_still_short_circuits_on_a_LIVE_dist_link negative control: a resolving link still short-circuits via has_dist and no build runs
test_discard_path_detaches_a_live_dist_link_without_deleting_its_target the entry goes; a bystander file inside the link's target is still readable
test_discard_path_removes_a_dangling_dist_link the dangling shape is removed too
test_discard_path_still_removes_a_real_tree_and_a_plain_file negative control: the two non-link shapes keep their existing handling

Every link is created with the product's own platform_compat.symlink_or_junction,
not a bare os.symlink — so each test exercises whichever shape the running
platform actually produces, and the Windows shards get the junction.

Guard-the-guard. Each test asserts the planted shape through oracles outside
the module under test before touching the code under test:
platform_compat.is_link_or_junction(...) is true, and for the dangling cases
not .is_dir() plus prov.has_dist(co) is False — without that last one
build_dist short-circuits and nothing below would be under test at all.

Red-before, measured. build_dist, against unpatched origin/main:
FileExistsError: [WinError 183]. _discard_path, with the production hunk
reverted and everything else in place: 2 failed (assert not True — the link was
still there), the negative control still passing.

Control for pre-existing noise: test_pod.py +
test_frontend_edition_build.py + test_frontend_dist_resolve.py report the same
4 failed on pristine origin/main as with the patch, and 298 → 303 passed
exactly the 5 tests added. Those 4 are cp950 locale errors specific to this
Windows box, not a regression.

Gates on this head: flake8 clean, isort --check-only clean, mypy --platform linux reports nothing in either production file, black and subprocess-encoding
gates pass scoped origin/main...HEAD (4 files). test_pod.py is not in
.github/black-baseline.txt, so it was checked with black directly — the only
delta was one line I had just added, and it was reformatted; the three baselined
files were deliberately left unformatted so no unrelated churn rides along.

Manual verification

N/A — unit coverage sufficient: both defects are decided entirely by which branch a
link matches, and the tests build real links with the product's own helper on the
affected platform rather than mocking the predicate.

Related Issues

None — found by inspection while auditing is_symlink-based guards for the Windows
junction blind spot, the same family as #7881.

Pattern harvest

Rule candidate: semgrep
Pattern: a module that creates a directory link with
platform_compat.symlink_or_junction and then reads that same path back with
is_symlink() / os.path.islink(). The writer emits a junction on unelevated
Windows and the reader cannot see one, so a guard fails against the product's own
default output rather than against an adversary. Worth flagging mechanically:
symlink_or_junction and is_symlink appearing in one module is the signal, and
frontend.py had both, 170 lines apart.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title (feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)
  • Existing tests pass and new tests added for new functionality
  • Self-review completed; code follows project style guidelines
  • Documentation updated (if applicable)
  • No secrets, credentials, or internal references in the diff

Contribution License Agreement

🤖 Generated with Claude Code

…creates

`frontend._ensure_tree_dist` publishes `src/kiro_crew/static/dist` through
`platform_compat.symlink_or_junction`, which falls back to a directory JUNCTION
on Windows because a directory symlink there needs
SeCreateSymbolicLinkPrivilege. So on an ordinary unelevated box KiroCrew itself
puts a junction at that path. Two readers of the same path ask `is_symlink()`,
which reports False for one.

`pod.provision.build_dist` — the link check runs ahead of `is_dir()`/`is_file()`
precisely so a DANGLING link is still replaced. A dangling junction answers
False to all three (measured), so it fell through every branch and
`shutil.copytree` landed on a directory entry that still existed:
`FileExistsError [WinError 183]`, with no handler up the provisioning chain.

`frontend._discard_path` — its own docstring explains that `rmtree` refuses a
link even though `is_dir()` follows it, and then tests `is_symlink()`. A LIVE
junction therefore reached the `rmtree` branch, whose refusal
`ignore_errors=True` swallows, so the `.dist.previous.<pid>` entry was never
reclaimed; a dangling one was not removed either. The caller `os.replace`s the
served bundle onto that name, so a surviving entry makes the replace fail.

Both routed through `is_link_or_junction` / `unlink_link_or_junction`. The
symlink path is behaviour-preserving: `unlink_link_or_junction` calls
`os.unlink` for a symlink, exactly what both sites did before. The unlink half
matters as much as the predicate — a junction is a directory reparse point
detached with `rmdir`, so the target's contents survive; reaching for `rmtree`
once the junction is detected would delete through it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@leonlaiyc
leonlaiyc requested a review from a team as a code owner September 6, 2026 16:37
@leonlaiyc
leonlaiyc requested a review from cixuuz September 6, 2026 16:37
@github-actions github-actions Bot added fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 6, 2026
`Kiro Crew` is two words outside identifiers, and the docstring added in
the first commit joined them. The Fast Gate fails on it, and because the
heavy matrix waits on the Fast Gate, every backend and frontend job on
this PR was skipped rather than run — so this one word is what stood
between the change and any test evidence at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review (fork) — ✅ no blocking findings

Reviewed 261837a41cfb7cefed28cb2f9760562743c8ba64 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] 261837a

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review (fork) — ✅ no blocking findings

Reviewed 261837a41cfb7cefed28cb2f9760562743c8ba64 via the fork AI-review pipeline; updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] 261837a

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5, fork) — 🟡 CONCERNS

Design-level review of 261837a41cfb7cefed28cb2f9760562743c8ba64 via the fork AI-review pipeline — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Design-Verdict: CONCERNS

The fix is right, but the module has a third is_symlink reader of the same path — _stage_dist line 513 — left blind.

Watch

  • The PR's premise — "they are two readers of a single link" — undercounts: frontend._stage_dist also reads static/dist with static_dist.is_symlink() or static_dist.exists() (frontend.py:513, re-checked at :520). A dangling junction answers False to both, the move-aside branch is skipped, and os.replace(tmp_dist, static_dist) lands on a directory entry that still exists — the same "Could not stage static/dist" refusal this PR fixes one hop away in _discard_path. The trigger scenario is identical (linked tree cleaned on unelevated Windows), so the availability failure this PR targets survives through a third door in the same module. Extend the same is_link_or_junction substitution to those two checks, here or in an immediate follow-up.

Suggestions

  • Three call sites in frontend.py alone now hand-roll the link-first removal ladder (_ensure_tree_dist's Case-3 block, _discard_path, and build_dist's copy in provision.py); a single platform_compat.discard_entry helper would make the semgrep pattern harvested in the description unnecessary.

[DESIGN-REVIEWED] 261837a

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5, fork) — 🟡 CONCERNS

Premise-level review of 261837a41cfb7cefed28cb2f9760562743c8ba64 via the fork AI-review pipeline — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

All claims in the description verified against the base tree; siblings counted. Final review:

First-Principles-Verdict: CONCERNS

Both fixes are real and mechanism-level, but the same module reads the same path with the same blind is_symlink() two more times, unfixed.

What this change ships

Intent: make provisioning and staging recover when the static/dist link KiroCrew itself writes is a Windows junction. This is a FIX.

  1. pod up/provision over a dangling static/dist junction restages instead of an unhandled FileExistsError — justified
  2. Junction-shaped .dist.previous.* entries are reclaimed, so staged publication is no longer refused — justified
  3. Five tests pinning both behaviors plus two negative controls — justified

Both hunks route through the existing canonical pair (platform_compat.is_link_or_junction / unlink_link_or_junction, platform_compat.py:3668/3714), which is the repo-mandated mechanism, not a second spelling. The symlink path is behavior-preserving as claimed (os.unlink for a symlink). No new surface: no key, flag, or exported symbol.

Watch

  • Point patch with counted unfixed siblings on the same path. The description says "they are two readers of a single link" — I count four is_symlink() reads of static/dist in base (grep is_symlink() over src/): the two fixed, plus frontend.py:513 (static_dist.is_symlink() or static_dist.exists()) and frontend.py:520 in _stage_dist_locked. A dangling junction is False on both, so the move-aside is skipped and os.replace(tmp_dist, static_dist) at frontend.py:517 lands on the surviving directory entry — the same "Could not stage static/dist" failure class this PR fixes the backup-side of. Same two-line fix, same module, in scope.
  • The wider is_symlink audit family (e.g. seed.py:521, deploy/__init__.py:45, pod/runtime.py:2479) is genuinely larger — accepted as deferred; the author's own semgrep harvest covers it.

[FIRST-PRINCIPLES-REVIEWED] 261837a

@github-actions github-actions Bot added readiness: checking Automated validation is still running readiness: action required A blocking check or review needs attention and removed readiness: action required A blocking check or review needs attention readiness: checking Automated validation is still running labels Sep 7, 2026
@leonlaiyc

Copy link
Copy Markdown
Contributor Author

CI attribution for 261837a41 — the red is inherited from the base, not from this branch.

Both failing children (Backend Tests (3.12, 3) and Backend Tests (Windows) (3)) fail on the same single test, and Coverage Gate is derivative of them:

FAILED test/test_security.py::TestIsSensitiveBashCommand::test_chained_cd_expansions_do_not_blow_up_the_gate
AttributeError: module 'kiro_crew.security' does not have the attribute '_dir_holds_sensitive_leaf'

The run checked out Merge 261837a41 into aba8d79c4, so the tested tree is this branch on top of main@aba8d79c4. At that commit main is internally inconsistent:

Already repaired upstream. cbdd4a569"fix(security): repair chained-cd gate test after #9089 helper removal (#9182)" — rewrites that test to assert the helpers are absent by name. git merge-base --is-ancestor cbdd4a569 aba8d79c4 is false (the run predates the fix) and ... cbdd4a569 origin/main is true.

This branch touches src/kiro_crew/frontend.py, src/kiro_crew/pod/provision.py, test/test_frontend_edition_build.py and test/test_pod.py — no path to security.py or test_security.py.

Not rerunning and not rebasing: the next run against current main picks the fix up on its own, and churning the SHA to buy a green square would discard the exact-head review bodies for no change in what ships. All four fork lanes are clean on this head (GPT and Opus no findings, Design and First Principles CONCERNS, advisory) with zero [BLOCK-MERGE].

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

fork Pull request from a fork (external contributor) readiness: action required A blocking check or review needs attention

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant