Skip to content

fix(dev-fleet): isolate the sync runner and pin git's object graph - #7142

Merged
bolichen97 merged 1 commit into
mainfrom
fix/dev-fleet-skip-frontend-backend-only-sync
Sep 4, 2026
Merged

fix(dev-fleet): isolate the sync runner and pin git's object graph#7142
bolichen97 merged 1 commit into
mainfrom
fix/dev-fleet-skip-frontend-backend-only-sync

Conversation

@bolichen97

@bolichen97 bolichen97 commented Aug 30, 2026

Copy link
Copy Markdown
Collaborator

Problem / Motivation

Two gaps in the Dev Fleet sync's git-environment hardening, both about what git
answers, not what it executes:

  1. A refs/replace/<oid> ref substitutes one object for another in every git
    read. git replace is a legitimate, purely-local operation, but with it in
    place log, rev-list --count, merge-base, and merge --ff-only all answer
    about a substitute object graph — a history no checked-out commit names.
    Dev Fleet acts on every one of those answers as a statement about the checkout
    on disk, so a grafted walk makes "behind by N commits" (and the rebase/merge
    decisions built on it) simply wrong.

  2. wrap_argv's nested passthrough silently drops a caller's
    extra_hidden_dirs — an app backend already runs under
    KIROCREW_SANDBOX_ACTIVE, so a re-wrap returns the argv unchanged before
    the mask is consulted. Nothing was misled by this yet, but it reads at the
    call site like a security control and enforces nothing, which is a footgun for
    any future app-backend code that reaches for it.

Why it matters

(1) is a correctness pin first and a tamper pin second: a legitimate local graft
already produces wrong sync decisions, and a malicious one is an unaudited way to
steer them. (2) protects a future caller from building a boundary on a no-op.

What changed (motivation → approach → change)

  • _GIT_ENV_NEUTRALIZERS gains GIT_NO_REPLACE_OBJECTS=1. It is pinned as an
    environment variable (same precedence as git -c, overriding any
    agent-writable repo config) so it covers every git invocation from the module
    at one chokepoint. It is not one of the numbered config pairs, so
    GIT_CONFIG_COUNT stays at 4 — pinned by a test, since a neutralizer added as a
    fifth pair would silently disable core.sshCommand.
    platform/update_governance.py and auto_improvement's clone setup already pin
    it for the same reason.
  • A characterization test in test_sandbox_argv.py records that the nested
    passthrough drops extra_hidden_dirs today — explicitly a characterization, not
    a contract, so a future caller cannot be misled and so the behavior is visible
    if nested confinement ever becomes possible.
  • Docs (dev-fleet.md) gain a "Git environment hardening" section explaining
    the two jobs the neutralizer dict now does, and a note that the generated sync
    runner already carries -I (existing main behavior) as the isolation boundary a
    sandbox mask cannot substitute for.

Note on scope: an earlier head of this branch also added -I to the sync runner
and withdrew a frontend-build skip. Main has since superseded both — the sync
runner is now a digest-verified snapshot run by path under -I, and build+stage
is unconditional again — so after rebasing onto current main this PR carries only
the two additions above plus their docs. The runner--I goal is fully achieved
by main's snapshot design; the docs describe that existing behavior rather than
introducing it.

Tests

  • test_git_env_neutralizers_present — asserts GIT_NO_REPLACE_OBJECTS=1 is in
    the sync env and that GIT_CONFIG_COUNT stays 4.
  • test_the_neutralizers_answer_from_the_real_object_graph — runs real git in a
    tmp_path repo with a planted refs/replace graft and proves rev-list --count answers from the real graph under the neutralizer.
  • test_the_passthrough_silently_drops_extra_hidden_dirs — characterizes that a
    nested wrap_argv returns the argv with no mask and no launcher script.

All hermetic (every git runs -C <tmp_path> with global/system config pinned to
os.devnull and inherited GIT_* stripped).

Manual verification

N/A — unit coverage is sufficient; the neutralizer test exercises the real git
behavior end-to-end in a throwaway repo.

Related Issues

Refs #7132.

Pattern harvest

Rule candidate: agents-md

Pattern: a git/tool environment-hardening list that pins code execution (protocol, hooks, credential helper, sshCommand) but not which object graph / namespace the tool answers from. GIT_NO_REPLACE_OBJECTS=1 closes that gap here, matching the update_governance and auto_improvement clone-setup pins. Any site that trusts a git count/ancestry answer about the on-disk checkout should pin the real object graph alongside the protocol/helper pins.

Checklist

  • At most two commits (one is the norm), with a Conventional Commits title
  • 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

@bolichen97
bolichen97 requested a review from a team as a code owner August 30, 2026 23:47
@bolichen97
bolichen97 requested a review from pepmach August 30, 2026 23:47
@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: checking Automated validation is still running labels Aug 30, 2026
@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

Reviewed c8ac097ed450a40d3a6afb5a6a4a54160a116bd3 — this comment is updated in place on each push.

Review details

No findings.

[OPUS-REVIEWED] c8ac097

Verdict parsed from the review's SHA-scoped output markers for commit c8ac097ed450a40d3a6afb5a6a4a54160a116bd3.

False positive or not applicable? A repository writer can comment:
/ai-review override fable c8ac097ed450a40d3a6afb5a6a4a54160a116bd3: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

GPT 5.6 completed its review of c8ac097ed450a40d3a6afb5a6a4a54160a116bd3 and found no blocking issues.

This comment is updated in place on each push.

Review details

No findings.
[GPT-REVIEWED] c8ac097

False positive or not applicable? A repository writer can comment:
/ai-review override gpt c8ac097ed450a40d3a6afb5a6a4a54160a116bd3: <one-sentence reason>

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Design-level review of c8ac097ed450a40d3a6afb5a6a4a54160a116bd3 — updated in place on each push. A BLOCK verdict blocks PR readiness; PASS/CONCERNS are advisory.

Both cross-references check out: main already runs the sync runner as a snapshot under -I (worktree_ops.py:1707,1914), and update_governance.py:403 / auto_improvement/clone_setup.py:103 already pin GIT_NO_REPLACE_OBJECTS, so this brings the third git-consuming site in line with the existing pattern.

Design-Verdict: PASS

A real answer-integrity gap closed at the existing single chokepoint, consistent with the two sibling pins, with behavior-level (not presence-level) test coverage.

Suggestions

  • Retitle the commit: "isolate the sync runner" has no backing code in this diff (main's snapshot design superseded it, as the description notes), and the commit subject is the record a future release section is derived from — it should claim only the object-graph pin.

[DESIGN-REVIEWED] c8ac097

@github-actions

github-actions Bot commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Premise-level review of c8ac097ed450a40d3a6afb5a6a4a54160a116bd3 — 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 verification done. The env pin is genuinely a sibling of two existing pins, the -I docs paragraph is accurate (the runner is spawned via python -I -c <bootstrap> at worktree_ops.py:1912-1916), and I have my counts: the passthrough behavior is already pinned by an existing test, and two other git-env-hardening sites lack the graph pin the PR's own harvest note generalizes.

First-Principles-Verdict: CONCERNS

The graph pin earns its place; the sandbox characterization test is a rider whose fact an existing test already entails, and two counted siblings stay unpinned.

What this change ships

Intent: make Dev Fleet's sync decisions read git's real history, not a grafted substitute — a FIX.

  1. Dev Fleet git reads now ignore refs/replace grafts (GIT_NO_REPLACE_OBJECTS=1) — justified
  2. Real-repo test proving the pin against a planted graft, both halves asserted — justified
  3. Existing neutralizer tests pin the new key and that GIT_CONFIG_COUNT stays 4 — justified
  4. New 45-line test recording that a nested wrap_argv drops extra_hidden_dirs — rides along; near-duplicate of test/test_sandbox_argv.py:161
  5. Docs: "Git environment hardening" section in dev-fleet.md — justified (spec-same-commit invariant)
  6. Docs: paragraph on the runner's existing -I (unchanged behavior) — rides along, declared

Watch

  • The characterization test's zero option costs nobody anything today — the description concedes "Nothing was misled by this yet", and test_inside_sandbox_passes_through (test/test_sandbox_argv.py:161) already pins that the passthrough returns argv unchanged with no launcher, which entails the kwarg being dropped. Only the tie-breaker keeps this out of Blockers.
  • Point patch with 2 counted unfixed siblings. Grepped GIT_NO_REPLACE_OBJECTS in src/: 3 sites after this PR (dev_fleet, update_governance.py:403, clone_setup.py:103). The PR's own harvest note ("any site that trusts a git count/ancestry answer... should pin") also fits md_notebook/git_ops.py:281 _GIT_NEUTRALIZERS (its sync merges an agent-writable vault, git_ops.py:1118) and apps/registry.py:5251 (git pull --ff-only). Accepted-and-deferred, but the description doesn't say what is left.

Subtractions

  • Drop test_the_passthrough_silently_drops_extra_hidden_dirs; fold its one new assertion (the kwarg changes nothing) into test_inside_sandbox_passes_through at test/test_sandbox_argv.py:161.

[FIRST-PRINCIPLES-REVIEWED] c8ac097

@github-actions github-actions Bot added the merge conflict Branch has merge conflicts with its base — author must resolve before merge label Sep 1, 2026
@bolichen97
bolichen97 force-pushed the fix/dev-fleet-skip-frontend-backend-only-sync branch from 9caa6f1 to 1ad9f38 Compare September 1, 2026 07:44
@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 merge conflict Branch has merge conflicts with its base — author must resolve before merge labels Sep 1, 2026
@bolichen97
bolichen97 force-pushed the fix/dev-fleet-skip-frontend-backend-only-sync branch from 1ad9f38 to bfdaf34 Compare September 1, 2026 09:03
@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 1, 2026
@bolichen97

Copy link
Copy Markdown
Collaborator Author

Round 4: recommending withdrawal rather than a fifth patch

This is the fourth consecutive round of blocking findings in frontend_skip.py, and all three of this round's are one class: the skip fires when it should not, and a stale SPA stays served, reported as success. So per AGENTS.md's restructure rule I stopped patching and enumerated every predicate instead. No code changed and nothing was pushed — head is still bfdaf344f.

The predicate table

Three skip decisions, all in /workplace/bolichen/prdrive/wt-7142/src/kiro_crew/apps/builtins/dev_fleet/frontend_skip.py. For each: what it OBSERVES, what it INFERS, and every way the inference is false. Falsifiers marked [R3]/[R4] were found in earlier rounds; [NEW] I found this round. Every one is CONFIRMED empirically or structurally, not hypothesised.

=== A. should-we-diff — website_diff_is_empty(), line 261 ===
OBSERVES: `git diff --name-only <pre-merge HEAD> <ref> -- website` is empty.
INFERS:   "the inputs the frontend build reads are unchanged, so a rebuild reproduces the staged bundle."
FALSE WHEN:
 A1 [R3, closed] `base` spelled HEAD -> ref compared with itself -> vacuously empty on every sync. CLOSED by passing the pre-merge OID in the marker.
 A2 [R4-1, CONFIRMED] The build reads the WORKING TREE; the diff reads two commits. Verified in /tmp/gitprobe: `git merge --ff-only` SUCCEEDS with a dirty `website/` the merge does not touch, the commit-only diff reads empty, and `website/src/App.tsx` keeps its uncommitted edit plus an untracked `website/src/NewThing.tsx`. Both are silently never built. This is exactly the trap `kirocrew-worktree-dev` Rule 3 exists for ("Source .tsx edits are invisible until the website is rebuilt"), now sprung by the tool whose job is the rebuild.
 A3 [NEW, CONFIRMED] The build is NOT a pure function of `website/`. `website/vite.config.ts:192` runs `execSync('git rev-parse --short HEAD')` and `swVersionPlugin` stamps `${pkg.version}-${sha}` into `dist/sw.js`. So on a backend-only sync HEAD moves and `build(inputs) != staged artifact` BY CONSTRUCTION — the diff-empty premise "the bundle it would build is byte-for-byte the one already staged" (frontend_skip.py:432-438) is false on every single run the skip fires on. Harm is low (the SW is network-first and caches only the shell for offline fallback — I read website/public/sw.js), but it means byte-equality is NOT the property, and any stamp built on it never matches.
 A4 [NEW] Toolchain drift. A node/npm upgrade (`ensure-node.sh`) between the staged build and this sync changes the native rolldown/esbuild bindings and therefore the bundle, with an empty `website/` diff.
 A5 Cheap and CLOSEABLE: `git status --porcelain --untracked-files=all -- website` closes A2 exactly. I verified it does NOT reintroduce the round-3 "never fires" defect: `website/.gitignore` ignores `node_modules` and `dist`, and the check returns 0 entries on a pristine checkout, so it stays clean on a fully built one. A3/A4 are not closeable this way.

=== B. should-we-npm-ci — node_modules_matches_lockfile(), line 318 ===
OBSERVES: worktree `website/package-lock.json` bytes == the ref's; and `node_modules/.package-lock.json`'s `packages` map two-way-contains the lockfile's (optional entries waived).
INFERS:   "the on-disk node_modules IS the tree `npm ci` would produce."
FALSE WHEN:
 B1 [R3, closed] Hash-comparing the two lockfiles never matches (no root "" entry; host-ineligible optional builds) -> skip never fired at all. CLOSED by `_tree_satisfies_lockfile`.
 B2 [R4-2, CONFIRMED BY MEASUREMENT] The hidden lockfile is pure METADATA and nothing reconciles it with the files it describes. Measured in /tmp/npmprobe with a real offline install: `node_modules/.package-lock.json` is byte-identical after (a) deleting a file inside an installed package, (b) `rm -rf node_modules/<pkg>` leaving node_modules holding ONLY the hidden lockfile, and (c) truncating a file inside a package. All three read as "verifiably satisfies the lockfile".
 B3 [NEW] The reviewer's own suggested remedy ("verify the claimed package paths exist") closes only case (a2)/(b): my cases (a) and (c) survive it untouched. The falsifier list gets LONGER, not closed.
 B4 [NEW] Not closeable at a useful cost. npm's `integrity` hashes are over the TARBALL, not the extracted tree, so they cannot be re-derived from files on disk without re-tarring in npm's canonical form. Also uncovered: missing `node_modules/.bin` symlinks, postinstall/patch-package artifacts, and `website/package.json`'s own `postinstall`. Genuinely verifying the tree costs what `npm ci` costs.
 B5 `optional: true` waived without re-deriving npm's os/cpu matcher (28 such entries in this lockfile; the doc says 25 — see notes). Fails loud (a build error), not silent.

=== C. should-we-stage — staged_dist_matches_build_output(), line 399 ===
OBSERVES: `sha256(static/dist/index.html) == sha256(website/dist/index.html)`.
INFERS:   "the served tree is the output of the last build in this checkout."
FALSE WHEN:
 C1 [R4-3, CONFIRMED FROM SOURCE] `website/index.html` references public assets by STABLE, UNHASHED name — `/logo.png`, `/manifest.json`, `/icon-192.png`, `/sw.js`, plus the seven `/vendor/*.mjs` in the injected importmap. Vite copies `public/` verbatim. So a change to ANY of `website/public/**` (icons, fonts, app-assets, pcm-worklet.js, sw.js, and the load-bearing vendor shims) leaves the built `index.html` BYTE-IDENTICAL. Build ok + stage fail (ENOSPC, staging-lock OSError, a peer flow tripping `_incomplete_bundle_reason`, EXDEV) -> retry's own diff empty (the merge landed) -> index.html matches -> SKIP -> stale `/vendor/react.mjs` served forever. `website/public/` is touched by ~9 of the last few hundred merges, so this is a live path, not a museum piece.
 C2 [NEW, CONFIRMED — WORSE THAN C1 AND NOT CLOSED BY THE SUGGESTED FIX] `npm run build` is `tsc -b && vite build` (website/package.json; frontend.py:307 says so too). `emptyOutDir: true` lives INSIDE vite (vite.config.ts:698). So the dominant build failure — a TypeScript error in the merged code — means vite never runs and `website/dist` is left FULLY INTACT, identical to `static/dist`. Demonstrated in /tmp/tscprobe. Chain: sync merges `website/` changes -> `tsc -b` fails -> step fails -> operator retries -> diff empty (merge landed), `website/` clean, dist present, index.html matches -> SKIP -> Pull+Build reports SUCCESS and the merged frontend is NEVER built. The PR's own test `test_does_not_skip_when_an_earlier_syncs_build_failed` passes only because it fixtures `build_output=None`; its stated premise ("`npm run build` empties website/dist before writing, so a failed build leaves no build output") is false for the most common failure. Critically, the reviewer's suggested fix — full staged/build-output TREE equivalence — does NOT close C2: both trees are the same OLD build, so they compare equal.
 C3 [NEW] Given A3, `website/dist/sw.js` differs from `static/dist/sw.js` after any build at a different HEAD while index.html matches. So the docstring's claim at frontend_skip.py:382-385 ("An out-of-band `npm run build` that never staged also reads as `False`") is simply wrong.
 C4 Not falsified: partial staging. `_stage_dist_locked` copytrees to a sibling and `os.replace`s, so `static/dist` is atomically either the whole old tree or the whole new one. The divergence is only ever whole-tree, which is why full-tree comparison seemed sufficient — and why C2 defeats it.

VERDICT ON CLOSURE: the list is longer, not closed. Rounds 3+4 produced five falsifiers; this round adds four more (A3, A4, B3/B4, C2, C3) and shows that the remedies proposed for two of the three findings are insufficient. Exactly one falsifier (A2) is cheaply and completely closeable.

The invariant, and why it is not cheaply checkable

THE PROPERTY A SAFE SKIP REQUIRES: for every input the skipped step reads, the artifact already on disk provably corresponds to those inputs as they are NOW. Concretely: static/dist == build(website/ working tree, node_modules, toolchain, env, HEAD) and node_modules == npm_ci(package-lock.json).

WHY EVERY FINDING IS ONE BUG, NOT FIVE: each of the three decisions compares a PROXY for that property instead of the property.

  • diff -> needs "inputs unchanged", compares two COMMIT trees (inputs are the working tree)
  • npm ci -> needs "tree contents correct", compares METADATA npm wrote once (nothing reconciles it with the files)
  • stage -> needs "served tree == built tree, built from current source", compares ONE FILE out of thousands (unhashed outputs never reach it)
    Each round narrowed one proxy; a narrowed proxy is still a proxy, which is why round 4's findings are the same shape as round 3's.

IS IT CHECKABLE CHEAPLY ENOUGH TO LEAVE THE OPTIMIZATION WORTH HAVING? No — for two independent reasons.

(1) node_modules cannot be verified below the cost of npm ci. npm's integrity hashes are over tarballs, not extracted trees, so nothing on disk lets you re-derive them. Measured: deleting an entire package, deleting a file inside one, and truncating a file all leave the hidden lockfile byte-identical. The npm-ci skip has no safe formulation and must simply be dropped.

(2) The build is not a pure function of website/, so byte-equality is not the property. swVersionPlugin stamps git rev-parse --short HEAD into dist/sw.js, so on every backend-only sync the correct rebuild output DIFFERS from the staged artifact. A content-addressed stamp over the real inputs therefore NEVER matches — that is round 3's "the skip never fires" defect returning by construction. Making it fire requires a per-file "differences that don't matter" allowlist maintained by hand against vite.config.ts, i.e. re-creating the proxy this exercise was meant to eliminate.

WHAT IS AVAILABLE (for a maintainer weighing a redesign, not for this PR):
a. Drop the npm-ci skip; keep npm ci unconditional. Kills falsifier class B outright and definitionally fixes the build's dependency input. Also cheap in relative terms: the build is the expensive half (47s and 176s are the two in-repo measurements, docs/system-specs/modules/auto-improvement-test-plan.md:382) versus ~20-40s for npm ci.
b. Add git status --porcelain -uall -- website (closes A2; verified it stays clean on a built checkout, so it does not un-fire).
c. Close the sync-created class with a DIRTY FLAG, not a comparison: set a sentinel before the frontend build step and clear it only on success. That closes C1 and C2 including a mid-stage kill, which no artifact comparison can. This is the one genuinely new mechanism; note it changes frontend.py's shared atomic staging path, which has five other callers.
d. Better shape than any skip: a dist CACHE keyed by git rev-parse <ref>:website (the tree OID positively IDENTIFIES the source, and package-lock.json lives inside website/ so the key pins the dependency set too). Restore instead of rebuild. Still owes one WRITTEN-DOWN exception for sw.js's CACHE_VERSION, defensible because the SW is network-first and caches only the offline shell.
Residual after all of a-d: A3 (the HEAD sha) and A4 (toolchain drift). Neither is closed by anything cheap.

Recommendation

WITHDRAW #7142. I changed nothing and pushed nothing; head is still bfdaf34 and the remote lease matches, so nobody else has touched the branch.

THE ARITHMETIC.

Benefit. A Pull+Build merges everything since the last sync, so the skip needs the whole RANGE to be backend-only, not one commit. Measured over main's last 400 commits: 30.8% touch website/. Windowed:
1 commit merged -> 69.2% of syncs could skip
3 -> 36.2%
5 -> 20.2%
10 -> 8.4%
20 -> 5.0%
28 -> 2.9%
40 -> 0.0%
The one real cadence data point I have is this task's own worktree: 28 commits behind main in under a day. At that cadence the skip fires on ~3% of syncs. Saving when it fires is ~1.5-4 min (npm ci ~20-40s on 1029 packages + a build measured at 47s and 176s in this repo). Expected value: single-digit seconds per Pull+Build at a realistic cadence, ~45s at an unrealistically eager one.

Cost. Nine falsifiers across three predicates, all fail-DANGEROUS in the same direction — a stale SPA served behind a new backend, reported as SUCCESS — which kirocrew-worktree-dev Rule 3 names as the most expensive local-dev failure there is. Two of the three suggested remedies are proven insufficient (B3, C2). Closing what IS closeable requires dropping half the feature, a new dirty-flag transaction inside frontend.py's shared atomic staging path, and a hand-maintained allowlist against vite.config.ts — and still leaves A3 and A4 open. That is a materially different PR, not a fifth patch round.

The strongest single argument is C2, which is new this round: npm run build is tsc -b && vite build, so a TypeScript error in merged frontend code leaves website/dist intact, the retry's every gate passes, and Pull+Build reports success having never built the merged frontend. It is the most likely build failure there is, it survives BOTH the current code and the reviewer's proposed full-tree fix, and it means the PR's own passing test rests on a false premise. 459 tests green, flake8/isort/black/mypy green — the gates cannot see any of this, which is the reason four rounds have not converged.

If a maintainer still wants the saving, the follow-up worth funding is (d): a dist cache keyed by git rev-parse <ref>:website, with npm ci left unconditional. That replaces every proxy with a positive identity — the tree OID IS the source — and it is the only shape here whose safety argument does not need a list of exceptions.

Not landable in this shape. Recommend closing #7142 with the predicate table above in the closing comment, and opening the cache-keyed follow-up separately if the ~3-20% hit rate justifies it. I did not close, comment on, or label the PR.

The three findings

  • Local frontend edits bypass the rebuild — Confirmed empirically: git merge --ff-only succeeds with a dirty website/, the commit-only diff reads empty, and an uncommitted website/src/App.tsx edit plus an untracked sibling are silently never built. Your suggested remedy is also the one falsifier here that is genuinely closeable and cheap — git status --porcelain -uall -- website returns 0 entries on a clean checkout and website/.gitignore already covers node_modules/dist, so it does not reintroduce the round-3 "never fires" defect. I am not patching it alone: it is falsifier 1 of 9 and the other two findings turn out not to have working remedies, so this belongs in the redesign rather than a fifth patch round.

  • Hidden metadata can mask a damaged dependency tree — Confirmed by measurement, and worse than described: with a real offline install I found node_modules/.package-lock.json byte-identical after deleting a file inside a package, after rm -rf node_modules/<pkg> (leaving node_modules holding only the hidden lockfile), and after truncating a file. Your suggested remedy — verify the claimed paths exist — closes only the whole-directory case; the other two survive it. There is no cheap complete remedy either: npm's integrity hashes are over tarballs, not extracted trees, so nothing on disk lets you re-derive them, and .bin symlinks and postinstall artifacts are uncovered too. Verifying the tree costs what npm ci costs, which means the npm ci skip should be dropped, not narrowed. That is a maintainer call, not a patch.

  • Index-only comparison misses stale public assets — Confirmed structurally: website/index.html names /logo.png, /manifest.json, /icon-192.png, /sw.js and the seven importmap /vendor/*.mjs by stable unhashed filename, and Vite copies public/ verbatim, so any website/public/** change leaves the built index byte-identical. Build-ok + stage-fail then reads as "provenance holds" and a stale vendor shim stays served. But your suggested remedy — full staged/build-output tree equivalence — does not close it: see the tsc -b chain below, where both trees are the same old build and compare equal. Closing this class needs a dirty flag set before the build step and cleared on success (which also covers a mid-stage kill), not a deeper comparison — and that means changing frontend.py's shared atomic staging path.

  • A tsc failure leaves website/dist intact, so the retry skips and never builds the merged frontendnpm run build is tsc -b && vite build, and emptyOutDir: true lives inside vite, so a TypeScript error (the most likely build failure there is) means vite never runs and website/dist is untouched — still byte-identical to static/dist. The retry then passes every gate: diff empty because the merge landed, website/ clean, dist present, index.html matching. Pull+Build reports success having never built the merged frontend. This defeats the full-tree-equivalence remedy proposed for the staging finding, because both trees are the same old build, and it means test_does_not_skip_when_an_earlier_syncs_build_failed passes on a premise ("a failed build leaves no build output") that only holds for a vite-stage failure.

  • The build is not a pure function of website/, so byte-equality is not the property a stamp can checkvite.config.ts shells out to git rev-parse --short HEAD and stamps it into dist/sw.js, so on every backend-only sync the correct rebuild output differs from the staged artifact by construction. That falsifies the module's own premise ("the bundle it would build is byte-for-byte the one already staged") and its claim that an out-of-band unstaged build reads as False. The harm is small — the service worker is network-first and caches only the offline shell — but the design consequence is not: a content-addressed stamp over the real inputs would never match, which is round 3's "the skip never fires" defect returning, and making it fire needs a hand-maintained per-file allowlist against vite.config.ts. Toolchain drift (a node upgrade changing the rolldown/esbuild bindings) is a second impurity the website/ diff cannot see.

Notes

  • Nothing was changed and nothing was pushed. /workplace/bolichen/prdrive/wt-7142 is clean at bfdaf34; the remote lease matches, so the branch is exactly as the reviewer saw it. wt-main was read only.

  • The PR is NOT all bad — two things in it are independently good and worth salvaging if it is closed: the -I on the runner interpreter (cmd = [sys.executable, "-I", "-c", script] in worktree_ops.py) closes the round-2/3 sys.path-shadowing hole where the merged checkout sat at sys.path[0] for a process the per-step sandbox does not cover; and the test_spawn_audit.py addition. Those deserve their own small PR rather than dying with this one.

  • Doc inaccuracy, low priority and only relevant if the PR survives: docs/system-specs/modules/dev-fleet.md and frontend_skip.py:160 both say "25 of 1029 entries" are optional. I measured 28 entries with optional: true in website/package-lock.json. The 25 is probably the host-INELIGIBLE subset on the machine where it was measured, which is a per-host number and should not be written down as a property of the lockfile.

  • Evidence is reproducible from three scratch dirs I left in /tmp: /tmp/npmprobe (real offline npm install; hidden-lockfile invariance under three kinds of tree damage), /tmp/gitprobe (ff-only merge over a dirty website/, commit-only diff reading empty), /tmp/tscprobe (tsc-fails-first leaves dist intact). Nothing was written inside any worktree.

  • Method note for whoever picks this up: the windowed hit-rate table is the number that decides this, and it is the one nobody had computed. Per-commit "70% of commits are backend-only" reads like a strong case; per-SYNC (which is what a Pull+Build actually merges) the same data gives 2.9-20%. The optimization's value was being estimated against the wrong unit.

Needs a maintainer ruling

  1. Close fix(dev-fleet): isolate the sync runner and pin git's object graph #7142, or fund the redesign? My recommendation is close. The safe residue is a materially different change (drop the npm-ci skip entirely, add a dirty-flag transaction inside frontend.py's shared atomic staging path, add the working-tree-clean gate) and it still leaves the HEAD-sha and toolchain-drift falsifiers open. A fifth patch round on this span would repeat the pattern the restructure rule exists to break.

  2. Is the measured benefit worth any redesign at all? 30.8% of main's last 400 commits touch website/, so a sync merging the ~28 commits this worktree fell behind in a day has a 2.9% chance of being backend-only; even a 5-commit sync is 20.2%. Against a 47-176s build, expected saving is single-digit seconds per Pull+Build at a realistic cadence.

  3. If yes, which shape? (a) skip only the build+stage with a dirty flag, or (d) a dist cache keyed by git rev-parse <ref>:website. I prefer (d): the tree OID positively identifies the source and package-lock.json lives inside website/, so the key pins the dependency set too — it replaces every proxy with an identity. It needs one written-down exception for sw.js's CACHE_VERSION, which is defensible because the SW is network-first and caches only the offline shell.

  4. Either way, npm ci must become unconditional again. Its skip cannot be made safe: the evidence it relies on is metadata that stays byte-identical while node_modules is emptied.


I have not closed, retitled or labelled this PR — withdrawing it is a call for the maintainer, not for the loop that found the problem.

@bolichen97 bolichen97 changed the title fix(dev-fleet): skip the frontend half on a backend-only sync fix(dev-fleet): skip the frontend build on a backend-only sync Sep 1, 2026
@bolichen97
bolichen97 force-pushed the fix/dev-fleet-skip-frontend-backend-only-sync branch from bfdaf34 to ca41c40 Compare September 1, 2026 23:45
@bolichen97

Copy link
Copy Markdown
Collaborator Author

span=c905bf2468b8 — src/kiro_crew/apps/builtins/dev_fleet/frontend_skip.py:464 "Pre-Vite failures can certify a stale bundle"

Disposition: FIXED — the mechanism was replaced, and half the suggested remedy was taken outright.

The rationale, quoted:

Merged TypeScript error -> tsc fails before Vite clears old dist -> retry sees equal old indexes and skips both steps -> sync reports success while serving stale frontend code.
Anchor: residual/guard-removal
Fix: Remove the skip markers so npm ci and build+stage remain unconditional.

The diagnosis is correct and it is the decisive case, not an edge one. npm run build is tsc -b && vite build and emptyOutDir lives inside vite, so a TypeScript error in freshly merged code means vite never runs and website/dist is left byte-identical to the static/dist copy it was staged from. The merge has landed by then, so a delta-based gate reads empty too. No comparison of two artifacts can tell that apart from a completed build, and it fails toward the permissive answer.

For npm ci the remedy is taken as written: it is no longer a skip candidate and runs unconditionally on every sync. Narrowing it was not an option — whether the on-disk node_modules is the tree npm ci would produce cannot be verified below the cost of running it (npm's hidden .package-lock.json is metadata nothing reconciles with the files it describes, and its integrity hashes are over the published tarball rather than the extracted tree), and npm ci is both the step that repairs such a tree and the cheap half of the frontend work.

For build+stage the gate is re-founded rather than removed, because the property a safe skip needs — "the served bundle was produced by a completed build from exactly the source on disk now" — is a statement about history, and a producer can record it even though no comparison can observe it. record_frontend_build now writes the website/ tree OID plus the staged bundle's digest into the checkout's own git directory, and the runner calls it only after the build+stage step has exited zero. The tsc failure never reaches that line, so the stamp keeps naming the older source and the retry rebuilds; test_does_not_skip_when_a_tsc_failure_left_the_build_output_intact models exactly the chain above, asserting first that both dist trees still hold the old bundle (so every artifact comparison would say "fresh") and then that the verdict is still False. The record also covers two failures no comparison of any depth can see: a process killed mid-stage, and unhashed public assets (/vendor/*.mjs, icons, sw.js) that no single-file digest describes.

Two further holes in the same class were closed while re-founding it, both of which a narrowed comparison would have kept: writing and honouring the record now require git status --porcelain --untracked-files=all -- website to be empty, because the build reads the working tree while a tree OID describes a commit; and the check resolves HEAD rather than the incoming ref, because merge --ff-only <ref> also exits zero — printing "Already up to date" — whenever the ref is an ancestor of HEAD, so on a checkout carrying local commits a ref-based check would skip a committed frontend change and repeat that on every later sync.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

Superseding the round-4 withdrawal recommendation

That comment recommended withdrawing this PR rather than patching a fourth time, on two claims. Both are answered by the rewrite that is now pushed, so the recommendation is withdrawn rather than acted on.

Claim 1 — two of the three remedies were insufficient. True of the suggested remedies, and that is why none of the three was applied as written. Each finding is closed by a different mechanism instead: the npm ci skip is dropped entirely (its tree cannot be verified below the cost of running it, so there is no safe narrowing); the index-only comparison is replaced by a provenance record written after a zero exit (which also covers unhashed public assets and a process killed mid-stage, neither of which any comparison sees); and the uncommitted-edit hole is closed by requiring website/ to be clean including untracked files.

Claim 2 — the safe residue is a materially different PR. It is a materially different mechanism, but the PR's purpose is unchanged and the surface is smaller, not larger: frontend_skip.py is 163 lines shorter than the reviewed version, five predicates are deleted, and npm ci is back to unconditional. A smaller change that closes the class is the outcome the restructure rule is for.

What is genuinely left is a cost/benefit judgement no reviewer raised and that the rewrite does not move either way: whether a roughly 3–20% per-sync hit rate is worth a stamp file and a runtime gate. That is a maintainer call, and it is left as one.

@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 1, 2026
@github-actions github-actions Bot added readiness: action required A blocking check or review needs attention and removed readiness: checking Automated validation is still running labels Sep 2, 2026
@bolichen97
bolichen97 force-pushed the fix/dev-fleet-skip-frontend-backend-only-sync branch from 66373f7 to e6a620e Compare September 2, 2026 21:41
@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • src/kiro_crew/apps/builtins/dev_fleet/frontend_skip.py:464 — "Source identity can change between provenance reads" ([BLOCK-MERGE] 66373f77e; span=c905bf2468b8)

Disposition: the mechanism is confirmed and FIXED; the prescribed remedy is rebutted for the seventh time; and one branch of the class is a documented residual rather than a closed one — see the last section, which is a question for the maintainer rather than a claim. Pushed as e6a620e1e.

Concurrent website commit during the bundle walk -> new tree is paired with or approved against the old bundle -> a later sync skips and serves stale assets.
Fix: Capture HEAD:website before validation, re-read it afterward, and refuse unless both OIDs match.

This is a new mechanism, not a restatement. Rounds 1–2 were forged on-disk provenance, 3 the digest framing, 4 repo-controlled git config, 5 the record-refresh ordering, 6 replace-refs / index / exclude / clean filters. This one needs no adversary at all: may_skip_frontend read HEAD:website first, hashed the whole staged bundle, and asked about cleanliness last — and those two reads only cover each other at one instant, because an edit is visible to cleanliness while uncommitted and to the OID once committed. I reproduced it rather than reasoning about it: driving the commit from inside the bundle walk, the previous head returned True in two distinct interleavings.

Fixed by the remedy's own shape, applied one level deeper. website_source_identity(git, repo) is now the single read that answers "what frontend source would a build read now?" — cleanliness FIRST, then rev-parse HEAD:website, None on dirt, untracked files, or any failure. may_skip_frontend reads it twice, either side of the bundle walk, and requires both readings to be the recorded tree; build_record uses the same helper, so no caller can order the two questions the unsafe way. That covers both windows the lane's remedy names and the narrower one inside a single read.

Class closed by branch, not by instance:

Branch Status
(a) the window between the cleanliness and OID questions CLOSED — one function owns the order; no caller can invert it
(b) the wide window across the bundle walk CLOSED — the identity is re-read after the walk and must still match
(c) a change landing after the last read FLOOR — identical to the window an unconditional build has between reading the tree and reading a file
(d) a change landing between the build step and the record derivation RESIDUAL, documented, not closed — see below

Measured mutation matrix (each mutation applied to an isolated copy and the suite re-run):

Mutation Reds
drop the post-walk re-read exactly the during-the-walk test and the new post-walk test
invert the two questions inside website_source_identity exactly the new post-walk test

The new test drives its change between the post-walk read's cleanliness question and its OID question, which is where the order is actually decided — the earlier tests are pinned by the re-read and pass under either order, so they are not claimed as the order's pin. The previous head's claim that swapping the two questions failed the between-the-questions test was an artifact of that test's git helper hitting a no-op commit, and the PR description now says so.

The lane's remedy — revert to an unconditional build+stage — is declined on verified code, unchanged from round 6. The suppressed step is npm run build, whose build script lives in the same-uid-writable website/package.json, followed by _stage_dist copying the same-uid-writable website/dist, with npm ci running before it in the same sync. On the unconditional path an actor writes "build": "exit 0", drops its own bundle, and has it served under a sync reporting success. The skip path cannot reach that: staged_dist_digest is a live read compared against the recorded digest, so substituting a bundle is a sha256 preimage and staging one merely withholds the skip. I fixed the mechanism and rejected the remedy — the third outcome, not a waiver.

Branch (d), stated honestly, because the previous head's bound was wrong in the direction that matters. build_record fingerprints what is on disk after the run, so a website/ commit landing between the build step's completion and the derivation yields the pair (T1, D0) where D0 was built from the older tree T0. The window is small — build+stage is the sync's last step, so it spans the child's exit path and one executor hop, not the rest of the run — but the pair does not self-correct: a later backend-only sync finds T1 and D0 both still on disk, skips, and re-derives the identical pair, so it stands until a change moves the website/ tree OID or the backend restarts. The head this lane judged claimed the opposite (“one more sync or a backend restart”, “can never name code older than the tree it records”); both claims are now corrected in frontend_skip.py, docs/system-specs/modules/dev-fleet.md and the PR description. What the build actually read is not observable afterwards at any price, so this cannot be closed from the backend. Whether that residual is an acceptable price for the saved build is a maintainer's call, and it is put here as a question rather than presented as closed.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • src/kiro_crew/apps/builtins/dev_fleet/runtime.py:1064 — "the new completion-path _SYNC_LOCK acquisition inverts against _sync's single-flight wait, spuriously refusing a concurrent sync" ([OPUS-REVIEWED] 66373f77e; span=5c8cf8005e47)

Disposition: fixed, with the lane's first remedy applied verbatim. Pushed as e6a620e1e.

a second _sync() that acquires _SYNC_LOCK in the window where the subprocess has exited (proc.returncode is not None) but status is still "running" then enters await asyncio.wait_for(asyncio.shield(_task), timeout=2.0) (worktree_ops.py:1378) holding the very lock the worker's derivation now blocks on → the worker cannot complete, the waiter stalls ~2s and returns a false "sync already running"
Fix: don't hold _SYNC_LOCK across the executor derivation on the completion path

I confirmed the whole chain in the code rather than taking it: rc = await proc.wait() is a yield point that lands after the child exited and before the derivation runs, _refresh_frontend_build_record took _SYNC_LOCK across two git spawns and a full static/dist walk, and _sync holds that same lock across its 2s wait on the run task. The lane is also right that this contradicted the docstring I shipped beside it (“held only for assembly and never across a run”).

Why the remedy is safe, checked before applying it. The serializer that matters is the run TASK, not a second lock. _ACTIVE_RUNS[rid] is popped from the task's own done-callback, so while the derivation is in flight the gate always takes the stale-status branch and waits on the task the derivation runs inside — task not done means the pair is still being read, so the gate either waits for it (and then sees the published status) or times out and refuses. Neither outcome fetches and merge --ff-onlys underneath the derivation, and _sync_start_locked has exactly one caller, inside that gate. So dropping the lock removes the inversion without removing the property.

What landed:

  • _refresh_frontend_build_record no longer takes _SYNC_LOCK; the derivation is a plain run_in_executor.
  • The docstring bullet that claimed the lock cost nothing is replaced by the lock-ORDER statement, in runtime.py and in docs/system-specs/modules/dev-fleet.md.
  • Two tests: test_the_derivation_does_not_take_the_gate_it_would_invert_against (inverted from the previous head's test, which asserted the lock WAS held — verified by mutation: reinstating the acquisition reds exactly that test) and test_the_sync_gate_waits_for_the_run_task_before_it_spawns_again, which pins the serializer positively so it cannot be dropped as redundant later.

@bolichen97

bolichen97 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author
  • Suggestion: "the 'cannot verify node_modules' argument answers skip-ci-while-building, not skip-both — gating npm ci on the same verdict captures the remaining frontend cost" (advisory, on a PASS verdict; the Design lane prints no FINDING/BLOCKING lines, so there is no span= id to cite)

Disposition: accepted as sound, and deliberately deferred out of this PR.

nothing after npm ci in the step list consumes node_modules except the build being skipped, so gating npm ci on the same verdict captures the remaining frontend cost — the deferred-repair tradeoff (a damaged tree waits until a build actually needs it, when npm ci runs anyway) seems acceptable.

The reading of the step list is correct — build+stage is the last step and the only consumer — and so is the observation that the module docstring's argument is about verifying the tree, which is a different question from whether anything reads it this sync. Two reasons it is not in this change:

  • The fleet checkout is a working checkout, not just a build input. node_modules is consumed there by things the sync cannot see: npm run test, a dev server, an editor's language service. Today every sync leaves that tree correct by construction; a verdict-gated npm ci moves repair to “whenever a website/ change next lands”, and the operator who hits the damaged tree in between has no signal that a sync declined to repair it.
  • It widens the skip onto a step whose input the record does not describe. The recorded pair identifies the website/ tree and the staged bundle; it says nothing about what is installed. Extending a verdict onto a second step on the grounds that nothing consumes its output needs its own falsifier pass, and this file's history is six rounds of exactly that kind of widening being wrong in a way no test caught. npm ci is also the cheap half, so the remaining saving is the small end of the change.

Worth doing as its own change with its own evidence; not worth folding into the round that is clearing this one's red.

@bolichen97

bolichen97 commented Sep 2, 2026

Copy link
Copy Markdown
Collaborator Author
  • Watch: "Item 5 reaches beyond the feature — GIT_NO_REPLACE_OBJECTS in _GIT_ENV_NEUTRALIZERS changes behaviour in every Dev Fleet read, not just the verdict; a human should confirm grafted checkouts are as rare as the docs assume" (advisory, on a PASS verdict; the First Principles lane's concerns do not parse into FINDING/BLOCKING lines, so there is no span= id to cite)

Disposition: rebutted — the widening is real, and it is the repo's existing norm for this exact class of read rather than a new imposition.

an operator using git replace on the managed checkout changes behavior in every Dev Fleet read, not just the verdict.

The scope claim is accepted as stated: the var is on _GIT_ENV_NEUTRALIZERS, so it covers every Dev Fleet git spawn. What bounds it is what it changes and what the lane's own item 5 already counted:

  • It changes which object graph a READ answers from, never anything git writes. Every Dev Fleet git read is a statement about the checkout on disk — which commit is checked out, whether the tree is clean, what HEAD:website is — and a refs/replace/<oid> ref makes those answers describe a substitute object the working tree does not hold. So on a grafted checkout the neutralizer is what makes the answers true, in both directions: it is a correctness pin first and a tamper pin second.
  • The population question is already answered by precedent in this repo, at the two sites the lane counted: platform/update_governance.py:403 and apps/builtins/auto_improvement/backend/clone_setup.py:103 both set it unconditionally, on checkouts from the same population Dev Fleet manages. Dev Fleet was the outlier, not the innovator, and the equality-pinning test exists so the two spellings inside Dev Fleet cannot drift apart.

An operator who grafts a Dev Fleet-managed checkout loses a graft-aware view in the dashboard's git readouts; they do not lose an operation, and nothing they do is refused. That is the whole cost, and it is stated in runtime.py's comment beside the var.

@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 2, 2026
@bolichen97 bolichen97 changed the title perf(dev-fleet): skip the frontend build on a backend-only sync fix(dev-fleet): isolate the sync runner and pin git's object graph Sep 3, 2026
@bolichen97

bolichen97 commented Sep 3, 2026

Copy link
Copy Markdown
Collaborator Author
  • src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py:2177 — "'continue' keeps the frontend-build skip active despite the stated unconditional-build withdrawal" (span=e652daa61560; the lane's standing ask across four rounds, recorded earlier as "Post-run HEAD can be paired with an older bundle")

fixed — by taking the remedy the lane asked for. The finding is legitimate exactly as written, and the maintainer has ruled the documented residual not accepted, so this round does what the lane has asked for from the start:

Fix: Revert the frontend-skip integration so build+stage remains unconditional.

frontend_skip.py is deleted, _FRONTEND_BUILD_RECORD / _SKIP_MARKER / _refresh_frontend_build_record and the runner's skip branch are gone, and the build+stage step runs on every sync. Nothing in the head diff reads or derives a provenance record.

Why the alternative was not shipped. The obvious closure — record the website/ tree OID the build actually consumed, captured before the build step — is not available at any price this design can pay. The backend has no point at which it can observe that OID: the fetch is itself a runner step, so when the step list is assembled the ref the merge will name has not landed yet, and once the run has exited every read of that tree is the same post-hoc derivation the residual is made of. Only the runner sits between the two, and every channel from the runner back to the backend is one a sync step can write or reach at the same uid: the child's stdout carries worktree-controlled build output on the same fd, a file is forgeable by the very steps that already execute code from the incoming revision, and an inherited fd or a memory read is same-uid reachable in a platform- and kernel-setting-dependent way. Refusing exactly those channels was the premise the whole mechanism rested on, so paying for the fix with one of them would have withdrawn the argument that made the skip safe in the first place.

Why a narrowed version was not shipped either. Recording the pre-run tree OID and letting the verdict's live read decline on a moved tree closes the committed-edit arm, but a tree that changes and changes back inside one run still yields a pair that re-derives identically on every later skip — the same non-self-correcting stale pair with a smaller mouth — and it forfeits the skip after every frontend-changing sync as well. The -I hardening on the runner spawn and the GIT_NO_REPLACE_OBJECTS object-graph pin are independently good and stay; the optimization does not.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Watch: "a race-window record that never self-corrects … a human should ratify that trade before merge"

fixed — the human ratified it the other way, and the mechanism is withdrawn. The lane read the trade correctly and correctly declined to close it on the PR's behalf:

a website/ edit committed between the build's completion and the record derivation yields a pair that vouches for a bundle built from an older tree, and it re-derives identically on every later skip — standing until the tree OID moves or the backend restarts, unlike the unconditional build's one-sync staleness.

The maintainer ruling on that call was that the residual is not accepted, so frontend_skip.py, the in-memory record and the runner's skip branch are all removed and build+stage is unconditional again. There is no longer a pair to vouch for anything, which is why this is recorded as fixed rather than as a deferral. What survives from the round is the part that stands on its own: -I on the runner spawn, GIT_NO_REPLACE_OBJECTS=1 in the git neutralizers, and the characterization test for the nested-passthrough drop of extra_hidden_dirs — the last of which is what rules out ever re-deriving the on-disk-record-plus-sandbox-mask design this lane also verified.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Suggestion: "Give the residual a manual exit cheaper than a backend restart: a sync option (or existing force path) that drops _FRONTEND_BUILD_RECORD and rebuilds unconditionally"

rebutted as no longer applicable, not declined on its merits. The suggestion was well aimed at the design it was reviewing: an operator staring at a stale dashboard needed a lever shorter than restarting the app backend. It is moot at this head because the skip is gone — _FRONTEND_BUILD_RECORD no longer exists, every Pull+Build rebuilds and re-stages unconditionally, and the operator's existing button already is the unconditional rebuild. Adding a "rebuild unconditionally" option now would be a second spelling of the one path that remains, so the smallest honest response is to add nothing.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Watch: "GIT_NO_REPLACE_OBJECTS lands in the canonical neutralizer set, silently changing every Dev Fleet git call — not just the new skip's reads"

fixed — by making it the change's stated job instead of a rider. The lane's objection was about disclosure and blast radius rather than about the pin being wrong:

the widening is forced by the equality-pin test rather than by each call's own need; an operator using git replace on the managed checkout gets different behavior everywhere from a PR titled "skip the frontend build".

Both halves of that are answered. The forcing pressure is gone: the equality-pin test and the local _GIT_HARDENING copy it compared against went with frontend_skip.py, so GIT_NO_REPLACE_OBJECTS now stands on the reason it always had — log, rev-list --count, merge-base and merge --ff-only are what Dev Fleet's "behind by N commits" and its sync decisions are built from, and a grafted walk answers those about a history no checked-out commit names. And the blast radius is now the headline rather than a footnote: the PR is retitled fix(dev-fleet): isolate the sync runner and pin git's object graph, the body says the pin applies to every Dev Fleet git call, docs/system-specs/modules/dev-fleet.md gains a ## Git environment hardening section separating the two jobs the one dict does, and a behavioural test plants a real git replace --graft and asserts rev-list --count both with the graft honoured and with the pin — so an operator relying on git replace reads the effect in the spec instead of discovering it. platform/update_governance.py and auto_improvement's clone setup pin it identically, which is the precedent the lane itself surfaced.

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

Copy link
Copy Markdown
Collaborator Author
  • test/test_dev_fleet_app.py:3354 — "Inherited Git location variables can redirect fixture mutations" (span=1b8f4555de66; blocking on 63453f831; Anchor: no-test-side-effects)

fixed — the lane's remedy, applied through this module's own allowlist. The finding is legitimate as written and the chain is complete: env={**os.environ, **(env or {})} inherits GIT_DIR, which outranks git -C <repo>, so with one exported an operator running the suite would have this test's init / add / commit / replace write a real repository's index and refs instead of tmp_path's. That is the no-test-side-effects floor, and the test is one this PR adds, so it is in scope regardless of the PR's size.

Fix: Filter Git location variables from os.environ before overlaying env.

Done as an allowlist rather than a denylist, which is the same shape the repo already uses for this class (test_governance_updates.py builds its fixture env from update_governance.git_command_env() for exactly this reason, and says so):

base_env = {k: v for k, v in os.environ.items() if mod._is_safe_env_key(k)}
base_env["GIT_CONFIG_GLOBAL"] = os.devnull
base_env["GIT_CONFIG_SYSTEM"] = os.devnull

mod._is_safe_env_key is the predicate _build_env() itself filters through, so the fixture inherits PATH/HOME (and SystemRoot and friends on Windows) and nothing else. Naming the variables to strip would have left GIT_WORK_TREE, GIT_INDEX_FILE, GIT_COMMON_DIR, GIT_OBJECT_DIRECTORY, GIT_NAMESPACE and — specific to this test — GIT_REPLACE_REF_BASE and GIT_NO_REPLACE_OBJECTS, the last two of which would have decided the graft assertions before they were made. The allowlist also covers whatever variable git adds next.

The two GIT_CONFIG_* lines are the mirror-image bleed that the allowlist would otherwise have re-opened: test/conftest.py's autouse _git_identity fixture pins them for the whole tree, and filtering os.environ drops that pin, so an operator's commit.gpgsign or core.hooksPath would fail these fixture commits on their machine and nowhere else. Repository-local config still applies, which is what the test's own git config user.email relies on.

The fix is pinned, not just applied. The test now PLANTS the hostile condition instead of assuming its absence — monkeypatch.setenv("GIT_DIR", <decoy>) plus GIT_WORK_TREE, and a closing assert list(decoy.iterdir()) == []. Mutation-verified: restoring base_env = dict(os.environ) turns the test red (git init initialises the decoy and every later read answers about it), so the guard cannot rot into a comment.

Sibling audit, so this closes the class rather than the instance. Every git-spawning site reachable from the two test files this PR touches:

site env verdict
test_dev_fleet_app.py:3354 (this PR's) was {**os.environ, ...} fixed
test_dev_fleet_app.py:10048 _prune_dead_sync_base_refs fixture {**os.environ, GIT_AUTHOR_*, GIT_COMMITTER_*} same defect, pre-existing and not in this diff — left, and reported
test_dev_fleet_app.py:1225, :1248 {**os.environ, PYTHONIOENCODING} not git; no location class
test_sandbox_argv.py (all os.environ sites) patch.dict / argv assertions not git spawns

The :10048 site is a genuine instance of the same defect and the fix there is three lines, but it is a test this PR does not otherwise touch, 6,700 lines from anything in the diff, and the honest home for the whole class is test/conftest.py's _git_identity fixture — which already closes the config half for all of test/ and could close the location half in one monkeypatch.delenv loop. That is a test-infrastructure change with a much wider blast radius than a two-fix hardening PR should carry, so it is named here rather than smuggled in.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Watch: "One unfixed sibling of item 2 — md_notebook/git_ops.py's _auth_env builds the same neutralizer env without GIT_NO_REPLACE_OBJECTS" (advisory; the lane's verdict is 🟡 CONCERNS while every gate is green)

accepted-and-deferred — the sibling is real, I verified it, and I am naming it rather than in-scoping it.

md_notebook/git_ops.py:281 (_auth_env) builds the same neutralizer env without it, and that module runs git merge <remote_oid> (git_ops.py:1118) on a vault whose .git it already treats as attacker surface (its own gpgSign/hooksPath comments). The general fix is one env pair there; smaller than this change, so it belongs in scope or in #7132's follow-up, not unnamed.

Verified, with the path corrected. The module is src/kiro_crew/apps/builtins/md_notebook/git_ops.py, not src/kiro_crew/md_notebook/git_ops.py. _auth_env returns GIT_ALLOW_PROTOCOL, GIT_PROTOCOL_FROM_USER and one numbered GIT_CONFIG_* sequence, and it is spread into the env of the single git runner every vault op goes through — so the merge --no-edit --no-gpg-sign --no-verify-signatures <remote_oid> does run without the pin, on a repository the module's own comments treat as hostile. grep -rn GIT_NO_REPLACE_OBJECTS src/ confirms the four-site census the lane reports: dev_fleet/runtime.py:152 (this PR), platform/update_governance.py:403, auto_improvement/backend/clone_setup.py:103, and md_notebook absent.

Why it is not in this commit, stated plainly rather than left to inference. It is outside the three-dot diff — git diff origin/main...HEAD touches five files and none of them is md_notebook — and it is a different app's credential-bearing env builder. Landing it here means a sixth file plus the md_notebook spec in the same commit under the AGENTS.md same-commit rule, and a fresh review round across four lanes and every OS shard. That trade is not obviously good: this branch has drawn a blocking finding on nearly every round, including on a test it added last round, so a round is not free, and the lane's own framing allows the alternative ("in scope or in #7132's follow-up, not unnamed"). So: named, with the exact fix.

The exact follow-up, so it is not rhetorically deferred. Add "GIT_NO_REPLACE_OBJECTS": "1" to the env dict literal in _auth_env, alongside GIT_ALLOW_PROTOCOL and GIT_PROTOCOL_FROM_USER. It must go there and not into entries: entries feeds the numbered GIT_CONFIG_* sequence whose length becomes GIT_CONFIG_COUNT, and GIT_NO_REPLACE_OBJECTS is an env var in its own right rather than a config pair — the same distinction this PR's test_git_env_neutralizers_present pins for dev_fleet, where adding it as a fifth pair would have silently disabled core.sshCommand. The entries-derived count is computed with len(), so a plain env key disturbs nothing.

#7132 is not the right home for it and I have not filed it there: that issue is the backend-only-sync build skip this PR withdraws, and the PR body's trailer is now Refs #7132. rather than Closes, so it stays open for its own subject. This belongs to md_notebook's vault sync.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Suggestion: "wrap_argv's nested passthrough still silently accepts and drops extra_hidden_dirs — emit a logger.warning/SEL note at the drop site" (advisory; the lane's verdict is ✅ PASS)

accepted-and-deferred, and the remedy is narrower than the suggestion. The concern is exactly right and it is the whole reason this PR added a characterization test rather than reaching for a sandbox mask:

the test pins the fact, but only a reader of test_sandbox_argv.py learns it; emit a logger.warning/SEL note at the drop site so the next caller who builds a control on it is told at runtime, not by archaeology.

What is already there, which changes what the right fix is. sandbox.py:5367 does not drop silently in the log sense — the passthrough branch already emits a once-per-process logger.info (guarded by wrap_argv._nested_passthrough_logged) explaining that nested OS sandboxing is impossible by design. What it does not say is that extra_hidden_dirs / extra_visible_dirs given by this caller were discarded. So the correct change is not a NEW log site; it is one more clause in the message that already fires, conditioned on those arguments being non-empty. A second warning at the same branch would fire once per process for the many callers that pass nothing and teach them nothing.

Why not in this commit. This PR's entire touch on the sandbox layer is a test: git diff origin/main...HEAD -- src/kiro_crew/sandbox.py is empty. Its two production fixes are in dev_fleet/. Adding a message change inside wrap_argv turns a two-fix hardening PR into one that edits the module every agent spawn in the product routes through, and sandbox.py is a file where a log line is not free: test_security_posture.py's _BASELINE_LOG_SITE_CENSUS ratchets log/audit sites per module, and the sandbox.wrap_argv path is the one the harness-parity rules call out as failing OPEN. That is a change that deserves its own diff and its own review, not a rider.

Deferred with the fix specified, not just noted. Extend the existing logger.info at sandbox.py:5367 so that when extra_hidden_dirs or extra_visible_dirs is non-empty it also states that those masks are not applied on the nested path and that the caller must not treat them as a control. test_sandbox_argv.py::test_the_passthrough_silently_drops_extra_hidden_dirs — added by this PR — is the test that would then assert the message, so the pin the lane credits is what makes that follow-up cheap and verifiable rather than another archaeology exercise.

@bolichen97

Copy link
Copy Markdown
Collaborator Author

Open PR relationship audit

This is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion.

Relationship findings

  • PR #7383 is OVERLAPPING relative to this PR. The goals differ or the implementations can complement each other; this is not a duplicate claim. Recommended action for PR #7383: KEEP. Unrelated goals in the same file; the only interaction is possible context-line conflict for the later merge. Files: src/kiro_crew/apps/builtins/dev_fleet/worktree_ops.py. The two independent directions used different labels; the matrix conservatively retains OVERLAPPING for coordination.

No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit.

The generated sync runner is spawned with `python -c`, which puts the
inherited cwd at sys.path[0] ahead of the standard library. A module-style
app backend runs with cwd at the gateway's source root, and on the editable
install Dev Fleet exists to manage that is `<checkout>/src` -- the tree
being synced. So the runner's own startup imports resolve against the
checkout first, in the one process here the per-step sandbox does not
cover: only the step argvs go through sandboxed_spawn_argv. A
`src/shutil.py` an earlier sync landed, or that an agent wrote in the
checkout between syncs, therefore runs arbitrary code unconfined. It only
has to be on disk when the runner starts, so it races nothing. `-I` on the
interpreter takes the directory off the path for the process's whole life.
A sandbox mask cannot substitute: an app backend's `extra_hidden_dirs` is
silently dropped by `wrap_argv`'s nested passthrough, which now has a
characterization test so a caller is not misled by it.

Separately, `_GIT_ENV_NEUTRALIZERS` gains `GIT_NO_REPLACE_OBJECTS=1`. A
`refs/replace/<oid>` ref substitutes one object for another in every read,
so `log`, `rev-list --count`, `merge-base` and `merge --ff-only` answer
about a history no checked-out commit names -- and every git answer this
module acts on is a statement about the checkout on disk. It is a
correctness pin first, since `git replace` is a legitimate local
operation, and `update_governance` and `auto_improvement`'s clone setup
already pin it. It is an env var rather than a config pair, so
GIT_CONFIG_COUNT stays at 4.

The frontend-build skip this branch previously carried is withdrawn. Its
provenance record was derived from disk after the run, so a `website/`
commit landing between the build step completing and the derivation paired
the new tree OID with the older tree's bundle, and the pair did not
self-correct. Closing that needs the tree OID the build actually consumed,
which is knowable only inside the runner -- after the merge step -- and
every channel back to the backend is one a sync step can write, which is
the assumption the design rests on refusing. So build+stage stays
unconditional.
@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Blocker: "Delete the dangling #: block at test/test_dev_fleet_app.py:1045-1048" (advisory identity; the First Principles lane's concerns do not parse into FINDING/BLOCKING lines, so there is no span= id to claim)

Disposition: fixed — removed in c8ac097ed450a40d3a6afb5a6a4a54160a116bd3.

The lane is right: the #: block documented _LAST_SYNC_CMD ("the full runner ARGV from the last _run_sync"), a test global that was deleted in this rebase along with the two inline-runner tests it served, when main's snapshot-runner design superseded the branch's original -I hunk. The comment was orphaned residue with no variable beneath it — dead text the fix does not need. The four lines are gone; the object-graph pin and its tests deliver the fix unchanged.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Subtraction: "Drop test_the_passthrough_silently_drops_extra_hidden_dirs; fold its assertion into test_inside_sandbox_passes_through" (advisory; the First Principles lane's concerns carry no FINDING/BLOCKING span= id)

Disposition: REBUTTED — the existing test does not entail the new one's fact, and the fact is the security-relevant half.

test_inside_sandbox_passes_through (test/test_sandbox_argv.py:161) calls wrap_argv(argv, mode="strict") with no extra_hidden_dirs and asserts result == argv. It demonstrates only that a no-arg call passes through. The new test passes extra_hidden_dirs=(str(secret),) explicitly and asserts the secret path appears nowhere in result — i.e. that a requested mask is silently dropped. That is a different proposition: "argv is unchanged when nothing was asked" does not test "a mask a caller asked for does not exist." The footgun this documents is precisely that the passthrough returns success while enforcing nothing, so the test that a supplied kwarg is dropped is the one worth keeping. It is a characterization (declared as such), 45 lines, and reachable from the exact app-backend re-wrap shape Dev Fleet uses — proportional to guarding a caller from building a boundary on a no-op. Keeping it.

@bolichen97

Copy link
Copy Markdown
Collaborator Author
  • Watch: "point patch with 2 counted unfixed siblings; the description doesn't say what is left" (advisory; the First Principles lane's concerns carry no span= id)

Disposition: ACCEPTED AND DEFERRED — the sibling sites are real and out of scope for this PR; the Pattern harvest note already generalizes the class, and I'm naming the deferred set here.

The three current GIT_NO_REPLACE_OBJECTS sites (dev_fleet, update_governance.py, clone_setup.py) are the pre-existing pinned set this PR joins. The two unpinned siblings the harvest note's rule fits are md_notebook/git_ops.py's _GIT_NEUTRALIZERS (its sync merges an agent-writable vault) and apps/registry.py's git pull --ff-only. Each is a separate module with its own env-assembly path and its own tests; pinning them from this PR would widen a Dev-Fleet-scoped fix across unrelated call sites and re-arm every review lane on a much larger diff. They belong in a follow-up that owns those modules — which is exactly what the Rule candidate: agents-md harvest entry is for. This PR stays the smallest honest version of the Dev Fleet fix.

@chenmingwei23 chenmingwei23 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving: PR Readiness green (the repo's only required check), no failing lanes, MERGEABLE.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants