fix(design-critique): refuse a junctioned dir in the served signature - #9021
fix(design-critique): refuse a junctioned dir in the served signature#9021leonlaiyc wants to merge 2 commits into
Conversation
GPT 5.6 Review (fork) — ✅ no blocking findingsReviewed Review detailsNo findings. |
Design Review (Fable 5, fork) — ✅ PASSDesign-level review of CI does run Windows pytest shards ( My assessment: the problem is real (false digest mismatches on Windows → needless headless re-captures, false mid-capture staleness, Design-Verdict: PASS Root-cause predicate fix restoring a measured cross-language contract, using the repo's canonical junction-aware helper, with the Windows branch exercised in CI. [DESIGN-REVIEWED] d8a01a3 |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed |
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All checks confirm the change: base First-Principles-Verdict: PASS Only Windows exercises the junction path — confirm a Windows CI job actually runs the new test, since Linux CI sees only the symlink no-regression half. What this change shipsIntent: stop a Windows directory junction inside the build dir from moving the served-signature token for bytes the preview server never serves — a FIX. Inventory (4 items)
The defect has provenance a reader can check: the "Change both together" contract in [FIRST-PRINCIPLES-REVIEWED] d8a01a3 |
_served_signature digests the files capture-build.mjs will actually serve, and that script's header calls the two enumerations a contract: its readdir Dirent test counts neither a symlinked directory nor a symlinked file, so nothing behind one is ever reachable over the preview server. os.path.islink cannot hold up that contract on Windows. It reports False for a directory junction, so the walk descended into one and signed bytes the server does not serve. Measured: Node reports the same junction as isSymbolicLink=true and indexes only the real file, while the walk's digest moved when a file behind the junction changed. That is the "false mismatches and needless re-captures" the docstring rules out; it also lets an unserved tree raise newest_mtime_ns, which feeds the discover-time mid-capture check, and counts those files against _SIGNATURE_MAX_FILES. Both guards in the function now use platform_compat.is_link_or_junction, which tries os.path.islink first, so symlink behaviour is unchanged. The files-loop guard is behaviour-preserving on its own -- a junction is a directory reparse point and never lands in os.walk's files list -- and is changed so both halves of one contract read through one predicate. The existing symlink test skips on Windows for want of SeCreateSymbolicLinkPrivilege, which is the one platform that has junctions; the new test uses a junction there and a symlink elsewhere. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Backend Lint & Type Check (3.12) failed on this branch's own test file: typeshed guards _winapi.CreateJunction behind sys.platform == "win32", so the direct reference is an attr-defined error whenever mypy checks this in-package test on Linux. Reproduced locally with `mypy --platform linux` (the default platform on a Windows box hides it) and confirmed gone after. The import is now a guarded module-level one, which is the shape the top-level-imports rule exempts and closes the review's advisory finding on the function-local import at the same time. CreateJunction is resolved with getattr, mirroring platform_compat's own `getattr(os.path, "isjunction", None)`. The Windows path still creates a real JUNCTION and nothing else. It is not routed through a helper that may fall back to os.symlink: symlink creation succeeds on a runner with Developer Mode enabled, and a symlink is the shape os.path.islink already refused, so the fallback would turn the red-before green for the wrong reason. A missing CreateJunction now asserts instead of silently degrading. Red-before re-verified against unpatched origin/main with the new helper in place; the containment test still fails there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
f0ce6af to
d8a01a3
Compare
|
Rebased onto main Clean rebase: no conflicts. Both commits replayed unchanged, and no behaviour was modified. Gates run locally on the changed files only ( Please review the rebased head. Note that a maintainer push makes the maintainer the last pusher, so under this repo's last-push rule a second approver is needed before merge. Reply here if anything looks wrong. |
Problem / Motivation
_served_signaturein the Design Critique backend digests the filescapture-build.mjswill actually serve, so/rendercan tell whether a cachedprobe screenshot still depicts the current build output. That script's own header
calls the two enumerations a contract:
The Python half held that contract with
os.path.islink, which returns Falsefor a Windows directory junction. So the walk descended into a junction inside
the build dir and signed bytes the preview server never serves.
Measured on Windows, with the same junction on both sides:
dist/vendor→ an outside dirreaddirSync(withFileTypes)(the mjs predicate)isDirectory=false isFile=false isSymbolicLink=true→ not walked; served URLs =["/app.js"]os.path.islink(main)False— soos.walkdescendedos.path.isdirTrueplatform_compat.is_link_or_junctionTrueWith main's guard, editing a file behind the junction — one the server has no
route to — moved the token's digest and raised
newest_mtime_ns.Why it matters
The signature's own docstring states the cost this direction: "signing something
the server never serves yields false mismatches and needless re-captures."
Concretely, on Windows:
cached screenshot for the whole
_PROBE_REUSE_TTL_SECwindow, and a re-captureis a full headless browser render.
newest_mtime_nsis the high-watermark the discover-time mid-capture check reads, deliberately including
directory mtimes so a deletion cannot hide. An unserved tree could raise it and
make a capture that is actually fine look stale.
_SIGNATURE_MAX_FILESpressure. Files behind the junction incrementseen,so a junction pointing at a large tree can push a small build over 20 000 and
make
_served_signaturereturnNone— which callers must read as "unknown"and refuse reuse entirely.
This is not a disclosure bug and the PR does not claim one. The token is a
digest and is never served; nothing behind the junction is read as content or
returned to a client. First Principles graded the same site "digest coverage, not
disclosure" when it named it on #8767, and that grading is unchanged here.
What changed (motivation → approach → change)
Symptom: the token moves for bytes the server will not serve, on Windows only.
Root cause:
os.path.islinkis the wrong predicate for the contract — the Nodeside refuses a junction, the Python side admits it. Change: both guards in
_served_signaturenow go throughplatform_compat.is_link_or_junction, whichis already imported in this module and tries
os.path.islinkfirst, so symlinkbehaviour is bit-for-bit unchanged and each guard is strictly widened.
Two guards, one contract:
dirs[:]prune — the load-bearing one, and the only site the measurementreproduces against;
is a directory reparse point and never lands in
os.walk'sfileslist. It ischanged so both halves of one contract read through one predicate, rather than
leaving a second spelling of the same guard behind. This is stated rather than
implied: it is not a second fix.
capture-build.mjsneeds no change — it was measured to already exclude thejunction, so the contract is restored by moving the Python side alone.
The docstring paragraph that asserted the two enumerations agree is corrected in
the same commit; it was the thing that was false.
Deliberately not changed: a junction in an ANCESTOR of
build_dir(thefirst_linked_ancestorcase)._probe_build_diralready anchorsbuild_dirlexically under the validated project dir, and an ancestor link is a different
threat with a different helper. Out of scope, and not measured here.
Tests
test_served_signature_refuses_a_junctioned_directory(in the existingdesign_critique/tests/test_backend_routes.py) pins:newest_mtime_nsis unchanged by that same edit — the field the mid-capturecheck reads, which the existing symlink test does not assert.
It carries a guard-the-guard assertion: on Windows the link must really be the
shape
os.path.islinkmisreads (islinkFalse,isdirTrue), so the testcannot pass against a plain directory and prove nothing.
Red-before, measured on unpatched
origin/main(routes.pyrestored in-tree,patch copied aside):
Green after:
110 passed, 1 skippedacrosssrc/kiro_crew/apps/builtins/design_critique/tests/.Platform honesty. The one skip is the pre-existing
test_served_signature_ignores_what_the_server_will_not_serve, which needsSeCreateSymbolicLinkPrivilegeto make a directory symlink and skips on Windowswithout it — that is, the existing test for this contract does not run on the
only platform that has junctions. The new test uses a junction on Windows (no
privilege required) and a symlink elsewhere, so on Linux CI it is a
no-regression check, not a red-before; only Windows exercises the fix.
Second commit — a type-check fix on this test file, no product change.
Backend Lint & Type Check (3.12)failed on the first head: typeshed guards_winapi.CreateJunctionbehindsys.platform == "win32", so the directreference is an
attr-definederror whenever mypy checks this in-package test onLinux. The import is now a guarded module-level one — the shape the
top-level-importsrule exempts, which also closes the review's advisory findingon the function-local import — and
CreateJunctionis resolved withgetattr,mirroring
platform_compat's owngetattr(os.path, "isjunction", None).The Windows path still creates a real junction and nothing else. It is
deliberately NOT routed through a helper that can fall back to
os.symlink:symlink creation succeeds on a runner with Developer Mode enabled, and a symlink
is the shape
os.path.islinkalready refused — so that fallback would turn theWindows red-before green for the wrong reason. A missing
CreateJunctionnowasserts rather than silently degrading, and the red-before was re-verified
against unpatched
origin/mainwith the new helper in place.Gates on this head:
flake8clean,isort --check-onlyclean,scripts/check_black_formatting.pyPASS scopedorigin/main...HEAD,scripts/check_subprocess_encoding.pyPASS.mypy --platform linuxreportsnothing for
design_critique/— run with that flag deliberately, because mypy'sdefault platform on a Windows box resolves
CreateJunctionand hides the CIerror. Its remaining 4 findings are in
apps/routes.pyanddashboard/state.pyand are Python-3.10-only artifacts of the localinterpreter (
asyncio.timeout,BaseException.add_note), not this diff.The
Build Desktop (ubuntu-22.04)red on the first head was not author-owned:the frontend build, AppImage,
.deband.rpmall completed and the job failedin
actions/upload-artifactwithETIMEDOUT. No rerun was requested for it.Manual verification
N/A — unit coverage sufficient. The behaviour is a pure function of a directory
tree, and the test builds the exact tree (junction included) that the defect
needs; the cross-language half of the contract was measured directly against
Node's
Direntpredicate and is quoted above rather than assumed.Related Issues
No issue. This site was named by First Principles on merged
#8767 and explicitly accepted-and-deferred there, then
re-measured against current
mainbefore this PR.Pattern harvest
Rule candidate:
review-promptPattern: an
os.path.islinkguard that must refuse a junction — the guard iscorrect on POSIX and silently open on Windows, where
islinkcalls a junction aplain directory.
Scoped deliberately as a review prompt, not a lint autofix or a sweep: #8767's
review cites #8165's ruling that these sites are decided one at a time on
harm, and the harm differs enormously between them (this one is digest
coverage; a sweep would lump it with deletion and write paths). The generalizable
part is "flag the site and ask what the guard protects", not "replace every
islink".Second, narrower pattern, and the one that hid this: a contract asserted across
two languages, tested on only one of them. The existing test for this exact
invariant skips on the only platform where it can break.
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)docs/spec covers_served_signatureContribution License Agreement
🤖 Generated with Claude Code