Skip to content

feat(apps): add Design Tweak builtin (visual select-to-edit) - #1122

Merged
michellemxm merged 1 commit into
mainfrom
feat/builtin-design-tweak
Aug 16, 2026
Merged

feat(apps): add Design Tweak builtin (visual select-to-edit)#1122
michellemxm merged 1 commit into
mainfrom
feat/builtin-design-tweak

Conversation

@michellemxm

@michellemxm michellemxm commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Problem

The "Design Tweak" visual select-to-edit tool only existed as an
externally-installed KiroCrew app (poke-and-prose, from a separate GitHub
repo). It should ship as a first-party builtin so it's discoverable in the App
Store, versioned with the gateway, and held to the repo's own quality gates.

Why it matters

External apps run out-of-tree with a federated ESM UI bundle, which forces
origin=local and blocks the gateway from spawning their backend. Graduating
the tool to a builtin makes it a supported, in-repo feature (compiled UI page +
gateway-managed subprocess backend) instead of a side-loaded app.

Fix (symptom → root cause → change)

Symptom: the app couldn't be a builtin — a shipped ui.entry ESM bundle flips
origin to local, and apps/backend.py refuses to spawn a subprocess backend
unless origin == "builtin". Root cause: the UI must be a compiled React page
(no ui.entry), not a federated .mjs. Change — graduate poke-and-prose
builtin design-tweak:

  • Backend (src/kiro_crew/apps/builtins/design_tweak/backend/server.py):
    stdlib HTTP server, binds loopback, reads PORT, exposes /health, and
    verifies the gateway's X-KiroCrew-Proxy HMAC on every non-health route
    (CWE-306). Manifest backend.entryPoint is the dotted module
    kiro_crew.apps.builtins.design_tweak.backend.server. The external repo's
    /self-update route and legacy env fallbacks are removed (a builtin is
    versioned with the gateway, not self-updated from an external repo).
  • UI: the 1.4k-line federated .mjs is converted to a compiled React page
    website/src/apps/design-tweak/DesignTweakPage.tsx (+ api.ts, types.ts),
    registered in website/src/apps/builtinRegistry.ts under /design-tweak. The
    three host-module hooks are rewired to SPA-native equivalents
    (react-router useNavigate, redux setPendingInput, a plain-fetch api).
    The header's update-check and GitHub-repo buttons are removed, and the panels
    are aligned to the sidebar.
  • Skill + asset: ships skills/visual-edit and the App Store icon at
    /app-assets/design-tweak/icon.svg.
  • Spawn audit: test/test_spawn_audit.py allowlists three benign,
    fixed-argv / operator-configured subprocess spawns (_lsof_fields,
    _h_pick_folder, _start_dev_proc) with justification, matching the existing
    code_reviewer/git.py precedent (routing a long-lived dev server is noted as
    a follow-up hardening candidate).
  • Packaging: setup.cfg adds the builtin's inject/ dir to package_data.

The external app repo (kc-app-design-tweak) is not modified.

Tests

  • Full backend pytest (14796 passed) — includes the two test_spawn_audit
    gates, which pass with the merged allowlist.
  • Full frontend vitest (3832 passed, 3 pre-existing skips).
  • isort / flake8 / mypy (411 files) clean; tsc -b and eslint clean.

Manual verification

Brought up an isolated pod, enabled the app from the App Store, and confirmed:
the /design-tweak page renders in the sidebar, and the spawned backend answers
/health and /queue with 200 through the HMAC-signed proxy.

Screenshots

design-tweak

No automatic takeover of the external install (was: a migration)

An earlier revision of this branch carried ~1167 lines in
apps/manager.py (+531) and test_app_manager.py (+645) that renamed a
user-installed app directory during gateway startup and moved its data/
forward, so users of the external app would be auto-upgraded to the builtin.

That is now dropped, and apps/manager.py is untouched by this PR.

Two mechanisms that already exist on main make it unnecessary:

  • register_builtin_apps() already stands down on an app directory it did
    not write (_builtin_owns_install()), so a user-installed design-tweak is
    never clobbered.
  • uninstall_app() already keeps data/ by default — it moves data/
    aside, removes the app dir, then moves data/ back.

So the upgrade path for someone coming from the external app is: uninstall it
once from Apps → Library → Uninstall, and the builtin registers into that
same directory on the next gateway start, inheriting the preserved queue and
history. Documented in the app's README.

Why dropping it is the right call. Automating those two clicks required
moving user-owned directories at gateway startup — the single largest failure
surface in the change (crash-interrupted renames, symlinked data/,
enabled-state carry-over ordering), and the source of most of the blocking
review findings this PR accumulated. The audience is only those who installed
the external app from its own repo, so the convenience bought is small and the
risk imposed on every other user is not. If auto-migration is wanted later it
belongs in its own small PR, where its risk gets review attention proportionate
to it rather than riding inside a 12.8k-line app change.

Security hardening in this round

Each item has a regression test:

  • All filesystem paths resolve through one canonical _contained() barrier
    (realpath + startswith), replacing barriers CodeQL could not model. This
    took the PR from 21 open CodeQL alerts to 1.
  • The dev proxy re-validates scheme/host against the loopback allowlist at the
    sink
    rather than trusting module state set elsewhere (closes a
    py/full-ssrf critical).
  • Upstream response headers are sanitised before being echoed
    (py/http-response-splitting).
  • Spawned dev scripts no longer inherit the proxy HMAC secret. That secret is
    this backend's only authentication, so an untrusted project dev script could
    otherwise forge signed calls straight to the loopback backend and bypass the
    gateway entirely. PORT is stripped too, so the child does not try to bind
    the backend's own port.
  • The dev proxy strips Cookie and Authorization on both the HTTP relay and
    the WebSocket handshake replay. Cookies are host-scoped but port-agnostic,
    so with the dashboard on 127.0.0.1 the browser was attaching its
    SameSite=Lax session cookie to proxied requests and it was being relayed
    verbatim into the user's own npm run dev process.
  • The injected overlay authenticates inbound messages by window identity plus a
    message-type allowlist, and learns the panel's origin instead of posting to
    "*".

Internationalisation

The page is authored in JSX (not createElement) so it matches every sibling
app and satisfies the i18n extraction gate. All user-facing strings are behind
i18nT('apps.designTweak.…') with real translations in all nine target locales
plus the regenerated en-XA pseudolocale; the send-button count uses i18next
plurals registered in pluralKeys.json.

Known dispositions

  • CodeQL py/path-injection in _valid_root — dismissed as false positive.
    The sink is a read-only p.is_dir() check, and naming an arbitrary folder to
    preview is the feature, so there is no enclosing base to contain the path
    in. realpath + expanduser removes all traversal first, then the resolved
    path is rejected if any component is in _DENIED_ROOT_PARTS
    (.ssh/.aws/.gnupg/.kube/.docker) or is_sensitive_path() matches.
    Same disposition as alerts 331–332 / 369–371 in file_explorer/server.py.

no linked issue: this ships a new builtin app that no filed issue requested — it graduates a previously side-loaded external app into a first-party builtin, so there is no bug or feature request for it to close. Recorded explicitly so the omission reads as deliberate rather than a forgotten closing keyword.

@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 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

GPT 5.6 Review — ✅ no blocking findings

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

This comment is updated in place on each push.

Review details

FINDING -- website/src/apps/design-tweak/DesignTweakPage.tsx:470 -- '#fff', '#4a464f', and 'rgba(0,0,0,0.25)' bypass the active theme -> Fix: replace them with existing semantic CSS variables.
[GPT-REVIEWED] 5ef6f79

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

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Arbiter — ✅ no blocking findings

Arbiter found no unresolved long-term items that require action before merging 44deb6fff22ffaa05651a079efe8a386ab1d71a6.

Second-order review for 44deb6fff22ffaa05651a079efe8a386ab1d71a6; this comment is updated in place on each push.

Review details

Arbiter-Verdict: PASS

No sub-threshold finding meets the long-term-impact bar.

Scope note: GPT 5.6's two findings (unbounded resp.read() on the proxied dev-server response / entry.read_bytes() classification path, and the count-only draft cap) are marked BLOCKING with a [BLOCK-MERGE] marker — they are above threshold and gate through the reviewer's own lane, so they are outside this arbiter's remit and were not re-judged here. Opus 5 reported no findings. That leaves the design and UX CONCERNS, none of which is a one-way door or concrete harm created by this diff:

  • Silent boot-time supersede of a user-installed app (Design) — the external install's directory is moved to apps-superseded/, not deleted, so the user's code (including a modified fork) is preserved and the swap is fully reversible after merge. The gap is discoverability (log-only trail, no dashboard notice), which is a notification/UX improvement, not a data-loss or contract lock-in. Demoted to follow-up.
  • Fork-capture via repository-URL identity with equal-version graduation (Design) — same reasoning: the ambiguous-case behavior can be narrowed later (require origin == "registry", or notify-instead-of-migrate) without any migration or breaking change, and no data is destroyed in the meantime. Follow-up.
  • Missing spec update for the supersede mechanism (Design suggestion) — a docs obligation under AGENTS.md, reversible in a trailing commit; docs gaps are explicitly outside the blocking bar.
  • All four UX Watch items and three Suggestions — dropped dispatch-failure feedback after navigate(), the truncated 11px status line, the Alt+S mode desync, English-only overlay strings, the "Connected" label, the ({{n}}) string, and the Dimensions-menu dismissal are every-error or polish issues in a brand-new, default-disabled builtin. None locks in a wire format, schema, or API; each is fixable in an ordinary follow-up PR.

Suggested follow-ups (open as issues — non-blocking)

  • Surface the app-supersede takeover in the dashboard (Design) — a one-time UI notice when register_builtin_apps() moves a user-installed app dir to apps-superseded/; a logger.warning is invisible to the user whose app just changed identity. Fix in the app-registry/graduation path plus the App Store UI.
  • Narrow graduation identity for modified forks (Design) — require matching install source (origin == "registry") or notify-instead-of-migrate when a same-version external install could be a locally modified fork. Fix in the _SUPERSEDED_EXTERNALS graduation check.
  • Document the supersede mechanism in docs/system-specs/modules/app-kit-platform.md (Design) — AGENTS.md requires spec updates with behavior changes; land it in the next commit touching this area.
  • Deliver dispatch-failure feedback at the destination (UX) — sendRequest's navigate(chatRoute())setStatus(…) ordering drops the explanation on an unmounted panel; use a toast/chat banner instead. Fix in DesignTweakPage.tsx.
  • Error-styled, wrapping status variant for *_failed statuses (UX) — the single muted, truncating 11px line hides decision-critical errors; reuse the existing devError presentation.
  • Alt+S overlay hotkey should post its mode change to the host (UX) — or be disabled when embedded, so the panel's Preview/Edit toggle never contradicts the live capture state. Fix in inject/select-to-edit.js (keydown handler) + the panel bridge.
  • Localize the injected overlay strings (UX) — "Reply / add a follow-up…", "Follow up →", "You"/"Agent", "Done"/"In progress" are hardcoded English inside a 10-language dashboard; pass strings down in the state message alongside the theme. Worth tracking against the repo's i18n CI rule, which the injected-JS path currently escapes.
  • Minor panel polish (UX suggestions) — "Connected" button should read as an action ("Disconnect") on hover/focus; spell out the ({{n}}) comment count in status.sent_request_to_session; give the Dimensions menu the same outside-click/Escape dismissal as the adjacent app dropdown.

[ARBITER-REVIEWED] 44deb6f

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

For a broader accepted-risk deferral, apply defer-longterm and explain why.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

UX Review (Fable 5) — 🟡 CONCERNS

Advisory UX-level review of 5ef6f79b2a5ad844bf97a64f136d09c5500b768d — updated in place on each push; does not block merge.

No committed screenshots in the diff (the PR image is a GitHub-hosted asset, not a file under the repo), so lens 12 doesn't apply. My findings come from the code and strings.

One notable issue: all failure feedback funnels into a single de-emphasized, truncated, unannounced status line.

UX-Verdict: CONCERNS

Every error — send, delete, capture, follow-up, add — lands in one 11px muted truncated status line with no aria-live, so a failed edit-send can go unnoticed and its "what to do next" gets cut off.

Watch

  • Muted/truncated/unannounced error surface. DesignTweakPage.tsx:1703 renders every setStatus(...) — including send_failed, delete_failed, capture_failed, follow_up_failed, dispatch_failed_unconfirmed — as text-[11px] text-muted truncate with no aria-live. Cause: error copy shares the same muted, single-line, truncated slot as routine success chatter → mechanism: a failed capture or delete is visually indistinguishable from idle, the message clips mid-sentence, and screen readers never hear it → user consequence: user believes an action succeeded when it failed, and the recovery instruction (e.g. dispatch_failed_unconfirmed's "Checking the session — the list will update") is truncated away. Severity: moderate — frequency low (errors only) × impact high (silent task failure) × persistence every error. Note the inconsistency: devError at :1843 already gets role="alert" + var(--danger). Fix: render error-class statuses non-muted (danger token), un-truncated, and wrap the status line in aria-live="polite".

Suggestions

  • The connected-state button (:1688) shows Connected and disconnects on click, with the disconnect action only in a title tooltip — invisible to touch/keyboard. Consider a visible affordance (e.g. hover-swap to "Disconnect") rather than a tooltip-only hint.

[UX-REVIEWED] 5ef6f79

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Design Review (Fable 5) — ✅ PASS

Advisory design-level review of 5ef6f79b2a5ad844bf97a64f136d09c5500b768d — updated in place on each push; does not block merge.

Design-Verdict: PASS

A real graduation problem, solved in the sibling-builtin shape, with the riskiest piece (startup auto-migration) deliberately cut and every residual risk given a named, tested disposition.

[DESIGN-REVIEWED] 5ef6f79

Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
Comment thread src/kiro_crew/apps/builtins/design_tweak/backend/server.py Fixed
@michellemxm
michellemxm force-pushed the feat/builtin-design-tweak branch from 6fb2d01 to 5a97098 Compare August 2, 2026 07:04
@michellemxm
michellemxm marked this pull request as ready for review August 2, 2026 07:04
@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 Aug 2, 2026
@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Opus 4.8 Review — ✅ no blocking findings

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

Review details

Based on my review: the candidate list contained no candidates to falsify (Step 1 is trivially satisfied), and my independent Step 2 examination of the two highest-risk surfaces — the stdlib HTTP backend (server.py: proxy-auth HMAC gate, _contained/_request_file containment, _valid_target SSRF guard, credential-header stripping on both proxy directions and the WS 101 handshake, _keep_redirect_local, _valid_root/_find_entry/_static_response sensitive-path screening, _child_env secret strip, content-based credential/PEM backstop, atomic writes, and _QUEUE_LOCK serialization) and the injected overlay (select-to-edit.js: window-identity + type-allowlist inbound gate, learned non-wildcard HOST_ORIGIN, bounded pending queue) — found each barrier soundly constructed and reachable-defect-free. I could not ground any new finding to the (a)/(b)/(c) bar at 80+.

No findings.

[OPUS-REVIEWED] 5ef6f79

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

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

@michellemxm
michellemxm force-pushed the feat/builtin-design-tweak branch from 5a97098 to 0c02de9 Compare August 2, 2026 07:34
@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 Aug 2, 2026
@michellemxm
michellemxm force-pushed the feat/builtin-design-tweak branch from 0c02de9 to 114f6ee Compare August 2, 2026 07:39
@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 Aug 2, 2026
@michellemxm
michellemxm force-pushed the feat/builtin-design-tweak branch from 114f6ee to a76ff1c Compare August 2, 2026 12:44
@iamwhatever

Copy link
Copy Markdown
Collaborator

🤖 Kiro Crew [operator: iamwhatever]: This PR has been inactive for 7+ days. I reviewed the blockers but they require your input:

  • This is a ~13,400-line change across 37 files that graduates an external app into a first-party builtin — a new subsystem that is beyond the pipeline's safe-to-auto-rebase threshold (>3000 lines / new subsystem). Rebasing and reasoning about it without risking behavior changes needs the author's design context.
  • The branch is currently CONFLICTING with main. Resolving those conflicts correctly depends on your intent for the builtin/app-origin boundary and the compiled-UI migration, which I shouldn't guess at.

When you've addressed these (rebased onto main / resolved conflicts), the pipeline will re-assess on its next cycle.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Picked this back up and force-pushed a rebase — here's what was going on and what changed.

Root cause of the stall: the branch wasn't just behind main, it had regressed. Comparing the force-push history, 55ea6a7c0 (2026-08-05 05:36) was the last commit after 15 real review rounds — properly rebased onto a clean recent main. The final squash (54450024f5, 2026-08-05 18:30) landed on an older main instead (visible in apps/manager.py, where a hardcoded builtin list that had already been migrated away on main got reintroduced), silently discarding those 15 rounds of fixes. The subsequent "merge main in" commit didn't fix that, so the PR sat on a regressed base and kept re-showing an already-fixed GPT finding.

What this push does:

  1. Rebuilt from 55ea6a7c0 (the last known-good state) instead of patching the regressed head.
  2. Fixed the one real finding that was never actually closed across all 15 rounds: GET /latest returned the raw request dict directly (unlike /queue//history, which route through _summarize()), so it never applied the _redact_thread() floor those two already apply. Flagged BLOCKING in round 1, still present in the regressed head. Fixed + regression-tested.
  3. Rebased onto current main (26 conflicts → 6, all mechanical: import merges, an additive registry entry, i18n key additions).
  4. Backfilled a real i18n gap the rebase surfaced: apps.designTweak.manifest.* was never added to en.json (breaking check-app-manifest-sync.mjs), and ja/ko (added to the project after this branch's base) never got any apps.designTweak content. Backfilled both, including the ja/ko catalogs, and fixed the mechanical style-guide violations that surfaced (full-width punctuation, katakana long-vowel marks, Korean placeholder particle dual-forms).

Verified before push: full backend pytest + frontend vitest (18236 tests) green, isort/flake8/mypy/tsc/eslint clean, i18n-check.mjs clean against origin/main. Squashed to one commit per the repo's single-commit convention, author attribution preserved.

@github-actions

github-actions Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

First Principles Review (Fable 5) — 🟡 CONCERNS

Advisory premise-level review of 5ef6f79b2a5ad844bf97a64f136d09c5500b768d — why this exists and whether the shipped surface is the smallest honest version. Updated in place on each push; does not block merge.

First-Principles-Verdict: CONCERNS

A well-cased graduation with the migration correctly deleted — but it re-implements pid→cwd beside platform_compat.process_cwd and adds the repo's third osascript folder picker.

What this change ships

Intent: let users install the visual select-to-edit tool from the App Store instead of side-loading an external app — an ADDITION (graduation), with a named cause (ui.entry forces origin=local, and apps/backend.py:1670 spawns backends only for origin == "builtin").

  1. Design Tweak card in App Store, page at /design-tweak — justified
  2. Register local folders and preview them served from disk — justified
  3. Detect/start a project dev server, frame it via injecting proxy — justified; _start_dev_proc sandbox routing declared as deferred
  4. Native macOS folder-picker button — duplicate of dashboard/handlers/knowledge.py:670 / md_notebook/server.py:1603
  5. Batched, pinned edit requests delivered into a per-app chat session — justified
  6. visual-edit skill bundled with the app — justified (agent-facing protocol; 6 sibling builtins ship skills this way)
  7. lsof listener→cwd discovery — partially duplicate of platform_compat.process_cwd
  8. Redaction allowlist entry in security_posture.py — undeclared, but mandated by the test_security_posture.py drift guard
  9. Spawn-audit allowlist for three fixed-argv spawns — justified, gate-mandated
  10. i18n strings in all locales + App Store manifest entry — justified, documented invariant

Watch

  • _h_pick_folder (server.py:3727) is the third in-repo spelling of the osascript folder chooser — grep "choose folder" = 3 (knowledge.py:676, md_notebook/server.py:204, here) — and the three resolve the binary three different ways (bare PATH argv, pinned /usr/bin/osascript, trusted_system_bin). They are already diverging; each new app that wants a picker will mint a fourth.
  • The security_posture.py allowlist entry is the one inventory item the description never mentions; it is gate-mandated, but declare it.

Subtractions

  • Delete _cwd_for_pids (server.py:1106) and its lsof -d cwd invocation — platform_compat.process_cwd(pid) already answers pid→cwd on macOS+Linux with no subprocess (existing consumer: dashboard/handlers/terminal.py:252); call it per detected pid.

[FIRST-PRINCIPLES-REVIEWED] 5ef6f79

@iamwhatever

Copy link
Copy Markdown
Collaborator

Two more rounds while getting CI green:

Fixed — GPT 5.6 finding. DesignTweakPage.tsx's request row had 3 sibling icon buttons (open-in-chat, archive, delete), tripping the max-two-buttons-per-row rule. Kept open-in-chat direct, moved archive + delete into a DropdownMenu (same pattern as CronFolderHeader.tsx).

Not fixed here — pre-existing, unrelated main breakage. src/test/SpecBuilderSpecStatePanelCov80.test.tsx (blocks a second answer while one is in flight, then re-enables) fails on pristine origin/main with zero relation to this diff — verified directly on a clean main checkout. Already tracked and being fixed in #3410. Rebased onto latest main again in case it resolved timing-wise; if it's still red when this reaches CI, that's #3410's scope, not this PR's.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Another GPT round, 3 findings this time — all fixed:

  1. Real security finding — origin sharing. All projects previewed from disk shared ONE static server (differentiated only by URL path), so they shared one browser-storage origin: switching the panel from project A to project B could expose A's localStorage/cookies to B's page. Fixed by giving each project its own dedicated loopback listener on its own ephemeral port — real origin isolation, not just a path convention. Added two regression tests (test_two_projects_never_share_a_browser_storage_origin, test_a_project_s_own_server_refuses_another_project_s_path) plus a resolver-level one (test_two_projects_own_recorded_origins_do_not_cross_resolve).
  2. Test-collection side effect. QUEUE_DIR/HANDLED_DIR were created at module IMPORT time, before any test's isolation fixture runs — so importing the module for any reason, including bare pytest collection, wrote real directories under the operator's ~/.kiro/crew. Moved directory creation into main(), the actual process entry point.
  3. Another two-button-row violation, this time the dev-server URL editor's Use/Cancel pair. Removed Cancel (Escape already cancels — same remedy GPT itself suggested) and the now-dead i18n key across all locales.

Local gates still all green (pytest 244/244 on the touched file, tsc/eslint/i18n-check clean).

@iamwhatever

Copy link
Copy Markdown
Collaborator

One more round, 2 findings — both fixed:

  1. Real security finding. _child_env() (the environment handed to an untrusted dev script) stripped KiroCrew's own secrets/capability vars but not SSH_AUTH_SOCK/GIT_SSH_COMMAND/GIT_SSH — so a project's dev script inherited the operator's SSH agent and could git push/ssh as them. Added those three to the strip list plus a parametrized regression test.
  2. Test pollution, sibling of the earlier one. test_roundtrip_inside_queue_dir called _write_request against the REAL server.QUEUE_DIR (no isolation fixture) — its finally cleaned up the file but left the created directory tree under the operator's ~/.kiro/crew. Added the isolated_queue fixture parameter (checked every other direct QUEUE_DIR/HANDLED_DIR reference in the file — this was the only unguarded one).

@iamwhatever

Copy link
Copy Markdown
Collaborator

Round against 2e46e7c20: 1 fixed, 2 rebutted with evidence (not appeasing — changing correct/intentional code to silence a finding would be worse here).

Fixed. The dropdown trigger button (my own addition from an earlier round) reused the request_number translation as its aria-label, so a screen reader announced it only as "Request N" — its actions were undiscoverable. Added a proper more_actions ("More actions for Request {{number}}") label, translated across all locales.

Rebutted — base64 credential scan. _contains_credential's own docstring already explains this exact trade-off: it deliberately uses only the labelled/vendor-prefixed pattern set, not the full redact_credentials() pipeline's base64-decode + entropy passes, because (1) security.py itself documents the entropy pass as "the HIGHEST false-positive-risk redaction rule in the module", (2) running both extra passes on every served asset costs real CPU on the preview hot path, and (3) a false positive here doesn't degrade quietly — it 403s and blanks a legitimate page on a surface whose entire job is faithful rendering. This was a considered, already-documented decision from the original review, not a gap. Happy to revisit if there's a concrete reachable case beyond what the docstring already weighed.

Rebutted — delivery.ts truncated-history duplicate. The suggested fix ("return unknown when the request ID is absent") would remove the 'missing' verdict entirely, which designTweakDelivery.test.ts:85 already pins as the correct, intentional behavior for a confirmed-empty session (so a genuinely-undelivered request can be resent). SlotTranscript (the GET /api/chat/slots/{key} response type) carries no completeness/truncation signal at all today, so distinguishing "searched and it's genuinely not there" from "the chained history read came back short" isn't answerable from this module without a backend API contract change — that's a real follow-up, but a bigger, separate change than this PR's scope, and the generic fix as worded would regress a tested case rather than fix the edge case.

/ai-review override gpt 2e46e7c: base64-scan trade-off is pre-existing, documented, and deliberate — see _contains_credential's own docstring; no new code introduced this gap.
/ai-review override gpt 2e46e7c: the suggested delivery.ts fix would regress a pinned, intentional test case (confirmed-empty session -> missing); the real fix needs a backend completeness signal that does not exist yet, tracked as a follow-up rather than blocking this PR.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Fixed the Brand Name Gate too — a comment I wrote used the joined KiroCrew instead of Kiro Crew (two words). BRAND_BASE_REF=origin/main python3 scripts/check_brand_name.py clean locally now.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Scope reduced: the automatic-takeover migration is dropped. apps/manager.py is now untouched by this PR — 1167 lines removed (apps/manager.py +531, test_app_manager.py +645). Diff went from 39 files / 13,979 lines to 37 files / 12,834 lines.

The reason it is unnecessary is that two mechanisms already on main cover it:

  • register_builtin_apps() already stands down on an app dir it did not write (_builtin_owns_install()), so a user-installed design-tweak is never clobbered.
  • uninstall_app() already keeps data/ by default — it moves data/ aside, removes the app dir, then moves it back.

So the upgrade path is: uninstall the external app once from Apps → Library → Uninstall, and the builtin registers into that same directory on the next gateway start, inheriting the preserved queue and history. Now documented in the app's README.

Automating those two clicks is what required moving user-owned directories during gateway startup — the largest failure surface in the change and the source of most blocking findings this PR accumulated (crash-interrupted renames, symlinked data/, enabled-state carry-over ordering, and the symlink-escape finding from the last round). The audience is only people who installed the external app from its own personal repo, so the convenience is small and the startup risk imposed on everyone else is not. If auto-migration is wanted later it should be its own small PR where its risk gets proportionate review attention.

This also resolves the two blocking findings from the last GPT round: the apps/manager.py:2282 symlink-escape finding is gone with the code, and the remaining server.py orphaned-data-on-project-re-add finding is the only substantive item left — that one is a pre-existing behaviour of the app's own project registry (removing and re-adding a folder mints a new project id), unrelated to the takeover, and I'd like a call on whether it blocks: it loses no data (the old records stay on disk under the old id), it just stops surfacing them in the panel.

Gates re-run after the removal: full frontend vitest 19,906 passed, design_tweak backend suite green, isort / flake8 / mypy / tsc -b / eslint-at-baseline / brand-name gate / I18N_BASE_REF=origin/main i18n-check all clean. Also rebased onto today's newest main (it had moved 10 commits).

@iamwhatever

Copy link
Copy Markdown
Collaborator

Local pre-push review dispositions — reviewed SHA 211a360904ee84393c11eb2dce198bce81c3fb99, now pushed as eb154fe52.

Two model-pinned local reviewers ran the CI contracts against this diff before the push (a gpt-5.6-sol mirror of codex-review.yml, a claude-opus-4.8 mirror of claude-review.yml + base-ref AUTOSDE.yaml). The GPT mirror reported 3 BLOCKING; the Opus mirror reported no BLOCKING and independently adjudicated all three. Recording each disposition with evidence.


1. server.py — dev-server subprocess inherits a credential-readable environment → REBUTTED (false positive as framed).

The claim was that env=_child_env(binary.parent) hands ~/.aws / ~/.ssh / ~/.kiro/crew reach to the child. _child_env() is a deny-by-prefix strip and already removes exactly the named env vector — KIROCREW_PROXY_SECRET, PORT, NODE_OPTIONS, SSH_AUTH_SOCK, GIT_SSH_COMMAND, GIT_SSH, plus every KIROCREW_* / KIRO_CREW_* var via _CHILD_ENV_STRIP_PREFIXES. So no credential or capability var crosses that seam.

The residual — the child process can read files as the host user — is not a boundary this diff crosses. The child is the project's own npm run dev, an operator-selected subprocess, declared as a BENIGN_SPAWN in test/test_spawn_audit.py alongside the existing code_reviewer/git.py precedent. The AUTOSDE sandbox rule targets agent-selected spawns; this is user-selected and identical in privilege to the operator running the same script in their own terminal. The suggested fix (remove automatic project-script execution) would delete the app's primary capability, so no code changed.

2. api.ts / preview iframe sandbox="allow-scripts allow-same-origin allow-forms" — dashboard cookie reachable past the stripping proxy → mechanism CONFIRMED, comment FIXED, hardening out of scope.

Both reviewers agree the mechanism is real, and it is worth stating plainly: cookies are host-scoped, not port-scoped, so nothing in the sandbox tokens stops a framed project script from navigating itself (or fetching with credentials: 'include') straight to the dev server's own loopback port, bypassing the proxy that strips our headers. The browser attaches the dashboard access cookie because the host matches, and HttpOnly does not prevent that.

Not treated as blocking, because it grants no capability the actor lacks: the only party positioned to receive that cookie is the previewed project's own dev server, which per finding 1 already runs unsandboxed as the host user and can read the dashboard token off disk directly. It is also conditional on cookie-auth being active at all.

What was wrong and is now fixed: the safety comment in api.ts conceded that ports don't separate cookies and then asserted origin separation "is what makes the sandbox safe" — an argument that did not cover the case it had just admitted. The comment now states the cookie reach explicitly, says it is accepted rather than mitigated, and names why. allow-same-origin itself is load-bearing for select-to-edit's DOM reach and was not removed. Closing the gap properly is a dashboard-auth change (a cookie hardened against sibling-port reach), which is not this app's to make.

3. server.py:_keep_redirect_local — malformed upstream Location raises ValueError outside the guard → FIXED.

Confirmed by both reviewers and by the file's own contract. _valid_target documents this precise seam (".port is a lazily-parsed property, so the ValueError only surfaces at the first reader") and guards accordingly at line 762, as does line 1490 — but _keep_redirect_local wrapped only the urlparse() call, which never raises, and then read parts.port outside it. A dev server answering Location: http://localhost:notaport/ therefore passed the loopback-host check and raised, the exception escaped the handler, and ThreadingHTTPServer closed the connection with no HTTP response at all — the preview breaking on a stderr traceback with no structured signal.

Fixed by moving the .port read inside the existing try/except ValueError, matching the two sibling call sites; an unparseable authority now forwards the header untouched, since an authority we cannot parse is not one we can prove is the upstream. Regression test test_malformed_port_is_forwarded_not_raised covers non-numeric, out-of-range and negative ports, and was verified to fail without the fix (ValueError: Port could not be cast to integer value as 'notaport') and pass with it.


Gates on the pushed commit: backend test_design_tweak_backend.py 248 passed; isort / flake8 / mypy / tsc / eslint (CI's own --max-warnings 1116) / brand-name / i18n:check clean; frontend vitest 19,938 passed. The backend and electron failures present in this environment were verified to have byte-identical failure sets on main using the same commands, so none are introduced here.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Answering the First Principles Review (Fable 5) 🟡 CONCERNS — it advises rather than blocks, and no earlier round replied to it. It should have; it found real over-granted privilege. Now on 6b26b1041.

Subtraction 1 — "cron": true, "events": ["visual-edit:request"], "network": true → FIXED, all three removed.

Verified each claim before acting. visual-edit:request has exactly one hit repo-wide (the manifest line declaring it) — no publisher, no subscriber. There is no cron or CronSDK use anywhere under design_tweak/. And network is the one that actually mattered to a user: it is consumed only as consent text (permissions.py:50 "it can make outbound HTTP calls", and again at :93), so the install dialog was making a claim about this app that is false — every socket the backend opens is loopback. Removing it is strictly less declared privilege and a more honest install prompt.

Worth noting for the record that cron: true was not inert: hooks_integration.py:252 runs app-owned cron cleanup on disable for any app declaring it. With zero jobs that cleanup was a no-op, so dropping the grant loses nothing.

Subtraction 2 — the repository field → FIXED, removed, along with the dead chain it fed.

One correction to the finding: repository was not unread. server.py read it into REPO_URL and served it as repoUrl on /projects, and types.ts declared it on ProjectsResponse. But the chain terminated there — nothing renders it, because the "open on GitHub" button was removed when the app graduated to a builtin (the code comment still described that button). So the conclusion holds even though the premise was imprecise: it was dead weight pointing at the external repo this builtin explicitly no longer tracks. Removed the field, the REPO_URL read, the now-orphaned APP_DIR that existed only to feed it, the response key, and the type member.

Subtraction 3 — bare lsof / osascript argv → FIXED, both resolved through trusted_system_bin.

This one is more than a consistency nit, which is why it got fixed rather than deferred: trusted_system_bin's own docstring names lsof as its motivating case, and states the hazard — a gateway's PATH can lead with agent-writable directories, so a bare argv lets a planted shim run with the gateway's environment. Both call sites now resolve from the fixed system directories and treat None as unavailable; the folder picker returns a picker_unavailable 501 rather than assuming the tool exists.

I checked the opposite failure mode before calling it done, since a pin can silently disable a working feature: trusted_system_bin("lsof") resolves to /usr/bin/lsof on Linux, and /usr/bin is in _TRUSTED_SYSTEM_BIN_DIRS, so macOS's /usr/bin/osascript resolves too. osascript returning None on Linux is correct and unreachable — that route already 501s off darwin. Dev-server detection is unchanged: the previous bare argv raised OSError[] on a host without lsof, which is the same degradation the pin gives.

Watch item — sibling counts → ACCEPTED-AND-DEFERRED.

Agreed and not attempted here. This is the 3rd macOS folder-picker copy (dashboard/handlers/knowledge.py:664, md_notebook/server.py:1603) and the 5th containment helper (steering.py:238, spec_builder/backend/routes.py:743, pptx_maker/backend/paths.py:142, apps/registry.py:653). Consolidating either is a cross-app refactor touching four to five call sites that have no other reason to change in this PR, and folding it in would put a shared-helper migration inside an app-addition diff. The review's own framing — "genuinely larger than this PR" — matches my read.


Gates on 6b26b1041: test_design_tweak_backend.py 248 passed; frontend vitest 19,938 passed / 1,236 files; isort / flake8 / mypy / tsc / eslint (CI's --max-warnings 1116) / brand-name / i18n:check / vite build all clean. Rebased onto 5a4b8082 (main moved three times during this round).

@iamwhatever

Copy link
Copy Markdown
Collaborator

Dispositions against 6b26b104197dc22e43936c2cff8f7d2954e3013c → now 82c9d1d14. Both findings fixed — both were legitimate.

1. server.py:797 — broad preview roots expose browser credentials → FIXED.

Confirmed. _valid_root had two screens and both test the root itself: _DENIED_ROOT_PARTS matches a path component (.ssh/.aws/.gnupg/.kube/.docker), and is_sensitive_path(real) asks whether the root is inside a protected location. Neither catches a root that merely contains one — $HOME has no denied component and is not itself sensitive, yet the preview servers serve any file beneath the chosen folder, so registering it turns ~/.ssh/id_rsa and a browser's cookie store into fetchable URLs for the previewed page's own script.

Fixed with the repo's existing primitive for exactly this direction, path_contains_sensitive() (security.py:4959), whose own docstring names the case: "a root that is an ANCESTOR of a credential store (the home directory itself, or a parent of ~/.ssh)". It is list-based rather than a filesystem walk, so it stays O(sensitive entries) even for a huge tree — no traversal cost on registration. This rejects $HOME outright, which also closes the specific Firefox profiles.inicookies.sqlite path in the report.

Tests: test_root_containing_a_credential_store_rejected, plus test_ordinary_project_dir_still_accepted to pin that the new screen does not reject a normal project folder (the opposite failure mode).

2. server.py:1273 — incomplete bodies exhaust handler threads → FIXED.

Confirmed, and it was worse than the body reads alone: none of the three handlers (Handler, _DevProxyHandler, _StaticInjectHandler) declared a timeout, and socketserver.StreamRequestHandler.timeout is None, so no socket timeout was ever applied. With ThreadingHTTPServer spending a thread and a descriptor per connection and protocol_version = "HTTP/1.1" keeping them alive, a client that declared a permitted Content-Length and sent nothing parked a handler thread indefinitely — before the HMAC gate, so unauthenticated. The size cap did not help, since it screens only the declared length.

Three changes:

  • timeout = _CLIENT_READ_TIMEOUT (30s) on all three handlers, which is what makes StreamRequestHandler.setup() call settimeout on the connection. This also covers the header read, which had the same exposure via a never-terminated request line.
  • Handler._read_raw_body now verifies it got the promised byte count and raises _IncompleteBody (a ValueError subclass, so it is caught ahead of the existing 413 arm) → 400 incomplete_body; a timeout maps to 408 body_timeout. Both set close_connection, because the undelivered bytes would otherwise be framed as the head of the next request on a keep-alive socket.
  • _DevProxyHandler previously swallowed a short read as body = b"" and relayed a truncated request upstream. It now refuses with 400/408 and closes rather than letting the dev server act on a partial body.

I checked the WebSocket relay for the opposite failure mode, since a connection timeout could tear down a legitimately idle HMR socket: _relay_ws pumps through selectors.select() and only recvs a socket already reported readable, and it carries its own _WS_IDLE (3600s) cap, so the 30s read timeout cannot fire spuriously there.

Tests: TestClientReadTimeout — the timeout attribute on each of the three handlers (parametrized), the stdlib base-class default that makes the declaration necessary, short-body → _IncompleteBody + close_connection, complete-body passthrough, the subclass relationship do_POST depends on; and test_incomplete_request_body_rejected proving through _relay_http that no truncated body reaches the upstream. Verified 7 of the 9 new assertions fail with the fixes reverted.


Gates on 82c9d1d14: test_design_tweak_backend.py 258 passed (was 248), test_spawn_audit.py + test_app_manifest.py 124 passed, isort / flake8 / mypy / tsc / eslint / brand-name / i18n:check / vite build clean. Rebased onto e2f569a3 (main moved again mid-round).

Process note for the maintainer, not a disposition. By the span= identity this repo's tooling reports, blocking findings have now landed in server.py in five separate rounds — past the point where prepare-pr says to stop patching instances and ask for a structural call. I do not think the mechanical stall rule is quite the right diagnosis here: the findings have been in genuinely different subsystems (env stripping, redirect parsing, path containment, socket lifetime), each independent, so there is no single deferred invariant that would have made them all unreachable. But the underlying signal is real — this is one ~3,600-line new file implementing a static file server, a reverse proxy, a WebSocket relay, a process manager and an HTTP API, and each audit pass finds new true positives in a different corner of it. That is a surface-area question, and it is the maintainer's call rather than mine.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Answering the UX Review (Fable 5) 🟡 CONCERNS on 6b26b104197d (code now at 82c9d1d14; none of these are affected by that push). Advisory, so each gets a written disposition rather than a silent pass.

Headline — every outcome lands in one muted, truncated 11px status line → ACCEPTED, valid, and the one I would fix first.

The observation is accurate: {status && <div className="px-5 py-1 text-[11px] text-muted truncate">} sits at the top of the left rail while the controls that trigger it (send bars, dev-server controls, action bar) are elsewhere, so Send failed: {{error}} is styled identically to Previewing from disk and truncates. A send failure is the one message in this app a user cannot afford to miss, since the request they just wrote is the work product.

Not fixed in this PR, and I want to be honest about the reason rather than dress it up: it is the largest of the UX items — an error-styled, wrapping variant plus relocating feedback next to each acting control touches every call site that sets status — and this PR is already carrying two rounds of security fixes on a 12.8k-line diff. Filing it as a follow-up with the devError presentation named as the reuse target, per the review's own suggestion.

Dimensions dropdown has no Escape / outside-click dismissal → ACCEPTED-AND-DEFERRED (cheapest of the three).

Confirmed asymmetry: the project dropdown implements both and dimsOpen implements neither, so the menu hangs over the preview until the trigger is re-clicked. The fix is genuinely small — reuse the project dropdown's existing dismissal effect — and it is the one item here I would happily fold in if the maintainer wants one more round. Holding it only because a push re-arms all 58 checks and I am deliberately not spending another full cycle without a maintainer signal (see the process note on the previous comment).

"Connected" button reads as a status badge but disconnects on click → ACCEPTED-AND-DEFERRED.

Valid — the destructive outcome lives only in a hover tooltip, which is invisible to touch and to keyboard users. Deferred because the fix needs a new label string across 14 locale catalogs plus en-XA, so it is not the one-line change it appears to be; batching it with the other UX work keeps the i18n churn in a single reviewable commit.

Suggestions:

  • status.dispatch_failed_unconfirmed "was sealed" → ACCEPTED, agreed it is internal seal-on-send vocabulary leaking into user copy. Same i18n batch.
  • Red retry bar needs a line explaining why it differs from a normal send → ACCEPTED, same batch.
  • status.sent_request_to_session bare ({{n}})ACCEPTED, same batch.

None of the six is a one-way door: no wire format, schema, or persisted contract is involved, so each is fixable in an ordinary follow-up without a migration. That is why I am comfortable deferring rather than blocking — but they are deferred decided, not dismissed, and I would rather the maintainer tell me to spend one more round on the dropdown dismissal and the status-line styling than have them ship as-is by default.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Dispositions against 82c9d1d14cd3509f76ab0e812b7dfe108c07cd19 → now c44fc1dc6. Both findings fixed, and the Coverage Gate — red on this PR since well before this round — is fixed too.

1. server.py:2987 — caller-controlled comment ids → FIXED, by taking the reported fix rather than the narrower one.

The report was right about the consequence and slightly off about the mechanism, so worth stating precisely. Validation already existed one line below the quoted snippet (_ID_RE.match(str(cid))), so a malformed id was already replaced. The actual defect was the missing str(): a JSON-number cid passes a str()-ed format check and then persists as an int, while /delete-comment and /thread both read cid from the query string and therefore always compare against a str — so 123 != "123" and the comment can never be deleted or replied to again.

A str() coercion would have closed that, but I checked whether anything actually needs to supply an id and nothing does: the injected overlay only reads data-kiro-cid attributes and never sends one, followUpTo is a separate field with its own validation, and the submit response hands the minted id back to the caller. So the id is now always minted server-side, which additionally closes the duplicate-id case the narrow fix would have left open (a caller could otherwise resend an existing well-formed id, and one delete would take both comments).

Tests: TestCommentIdIsServerMinted — numeric payload id not persisted, caller cannot force a duplicate, minted id matches the format the lookups expect. Verified 2 of the 3 fail with the fix reverted.

2. DesignTweakPage.tsx:629 — sessions run against the wrong project → FIXED.

Confirmed, including the claim that an endpoint already exists. POST /api/chat/slots reads only name / agent / model / folder_id, so a freshly adopted slot keeps the dashboard's default project, and the slot's project is what scopes the runner's file search and @-mentions and what loads <project>/.kiro/steering. POST /api/chat/slots/{slot}/project does exist (routes/chat.py:79api_chat_slot_project, body {"project": "<abs path>"}, validated for isdir and is_sensitive_path).

ensureSlot now binds the project before seeding, so the seeded turn is already scoped. The bind is wrapped in its own try rather than sharing the seed's: a failed bind is degraded (the seed still carries absolute paths) and must not swallow the seed with it. Tests: 5 in designTweakSlotContract.test.ts — the posted URL and body against a mocked fetch, slot-key encoding so a key cannot forge a path segment, and the ordering constraint that the bind precedes SESSION_SEED.

3. Coverage Gate — FIXED, server.py 58% → 85% (floor 80%).

I owe a correction here: I previously reported this PR as "only GPT blocking". That was wrong. Coverage Gate has been failing since 9fab285bd, which predates every change in the last several rounds — I had folded it into the PR Readiness rollup and never read it separately. The cause was per-file coverage: 1,618 statements in one new file with a single test file behind it.

Five new sibling test files, one per subsystem, all asserting behavior rather than merely executing lines:

  • test_design_tweak_discovery.py_valid_target's SSRF guard across every rejection vector, listener/port discovery
  • test_design_tweak_relay_paths.py_relay_http body/header forwarding and size caps, _relay_ws handshake sanitizing and the pump, _start_inject_proxy
  • test_design_tweak_devproc.py_child_env's credential strip asserted variable by variable, _pkg_scripts, _node_bin_dirs, _resolve_bin, _dev_command
  • test_design_tweak_queue_routes.py — every branch of /delete-comment, /send, /delivered, /clear, /thread, plus the follow-up submit paths
  • test_design_tweak_project_routes.py — project registration through _valid_root's three refusals, the dev-server routes, the macOS-only picker's off-darwin path

Verified with the repo's own gate rather than my own arithmetic: scripts/check_per_file_coverage.py --floor 80 --baseline .github/coverage-baselines/backend.txt exits 0 and does not flag the file. server.py is not in the baseline, so as a new file it has to clear the floor outright, which it now does at 85%.

On the four extra backend failures a reviewer may notice: the full suite reports 43 failures with these five files present and 39 without, but the delta is xdist re-sharding, not regression. All five extra tests pass in isolation, one different test (test_mochi_routes.py::...test_every_locking_call_in_async_code_is_offloaded) fails only without the new files, and every state mutation in the new files goes through monkeypatch (273 of them; zero raw os.environ or chdir writes). Adding files redistributes work across workers and surfaces a different subset of this suite's pre-existing order-dependent tests on this host.


Gates on c44fc1dc6: design-tweak backend suite 506 passed; frontend vitest 19,992 passed / 1,237 files; isort / flake8 / mypy / tsc / eslint (CI's --max-warnings 1116) / brand-name / harness-parity / docs-lint / scrub-lint / i18n:check / vite build all clean; per-file coverage gate green. Rebased onto 85cf65b22 (main moved 50 commits mid-round). The verify_vendor_manifest.py failure in my environment is local __pycache__ under _vendor/ from importing the vendored tree — untracked, identical on a pristine main, and invisible to CI.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Dispositions against c44fc1dc60f4626f2e19d72d4952440d8afe9478 → now 04d305825. All three findings fixed, each with a regression test verified to fail with the fix reverted.

1. server.py:2630 — static reads retain a symlink-swap traversal window → FIXED.

Legitimate, and the repo already had the right primitive. _contained proves containment by walking NAMES and the size check stat()s a NAME, so neither binds the bytes that read_bytes() later returns; O_NOFOLLOW alone would guard only the final component, so a nested directory swapped for a symlink between the walk and the open escapes the approved tree. hooks.safe_read_file_bytes_nolink's own docstring describes exactly this ("a nested directory swapped for a symlink between the tree walk and the open would silently escape the approved tree"), and it is the established chokepoint at 27 call sites — including dashboard/handlers/webapp_preview.py:399, which serves a preview file from within a root, the direct analogue.

The read now goes through it with within_root=str(root) and max_bytes=MAX_STATIC_BYTES, and a None return is refused with 403 rather than served as empty. The pre-existing stat() size check is kept ahead of it so an oversize file still gets its descriptive 413 instead of a bare 403 — that check is now explicitly the hint and the helper is the authority.

Tests: TestStaticReadIsPinnedToTheInode — asserts the read actually routes through the helper with the root (without it the helper cannot reject an fd that resolved outside), that the byte ceiling is passed, and that a refusal 403s rather than serving empty bytes.

2. server.py:3635 — progress posts can grow queue records without bound → FIXED.

Confirmed, and worse than disk alone: every append rewrites the WHOLE record, so an unbounded thread is quadratic rewrite work on top of unbounded storage. /thread is the agent's progress channel, so it is written far more often than a human comments, and a stuck agent looping on progress posts is the realistic path rather than an attacker.

Added MAX_THREAD_ENTRIES = 500, enforced on both append paths (request-level and per-comment) with a structured 429 / code: thread_entry_limit, mirroring the existing MAX_DRAFT_COMMENTS / draft_comment_limit precedent rather than inventing a new shape.

One deliberate carve-out worth naming: a text-free status update ({"status": "done"} with no text) appends nothing, so it is not refused by the cap. Blocking it would strand a comment whose thread happens to be full — the cap bounds storage, and it must not cost the agent the ability to resolve an item. That case has its own test.

Tests: TestThreadEntryCap in the queue-routes file (which owns the route harness) — refusal at the cap on both paths, the record verified not to have grown anyway, an off-by-one guard proving the last usable slot is still writable, and the status-only carve-out. Plus a structural test pinning that both paths are bounded, since a cap on one leaves the other free.

3. DesignTweakPage.tsx:982 — successful resend remains enabled → FIXED.

Confirmed, and the reported fix was the only correct shape. deliverSealed reports its own failures via setStatus rather than throwing, and returned undefined on all three paths (no session, dispatch threw, dispatch confirmed) — so at the call site "did not throw" carried no information. Clearing missingIds on no-throw would have hidden the retry button after a failed send; not clearing at all left sendMissing={missingIds.has(req.id)} true, so a second click re-dispatched the same edits.

deliverSealed now returns false on the no-session and dispatch-threw paths and true only after a confirmed sendChatMessage; resendRequest removes the id from missingIds only on true. If the batch is somehow still absent, verifyDelivery re-adds it from ground truth on the next settle, so the optimistic clear cannot strand a genuinely undelivered request.

Tests: 2 in designTweakSlotContract.test.ts — that the retry control is retired only on a confirmed dispatch (and specifically that a bare await deliverSealed(req, req) is gone), and that deliverSealed signals on every exit path.


Gates on 04d305825: design-tweak backend suite 514 passed; per-file coverage gate green (check_per_file_coverage.py --floor 80 exits 0); isort / flake8 / mypy / tsc / eslint / brand-name / i18n:check clean. Rebased onto feae7e84c (main moved twice during this round).

One unrelated red a reviewer may see: src/test/ArtifactDetailPageCoverage.test.tsx > changing the document type patches the record and reports failures fails in my environment. It fails identically on a pristine main checkout in isolation, touches nothing in this diff, and is almost certainly host timing in that file. Not introduced here.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Dispositions against 04d30582521cb1b1bc2cbd2a29bc6ff0b6adbda7 → now 603e463ae. Both findings fixed, each with regression tests verified to fail with the fix reverted.

1. server.py:672 — queue files are read without a size bound → FIXED.

Legitimate, and the reachability argument is stronger than "an injected agent": the bundled visual-edit skill hands the agent that exact directory (~/.kiro/crew/apps/design-tweak/data/queue/<timestamp>-<id>.json) and tells it to read records there, so a file in the queue dir is simply not guaranteed to be one this backend wrote through the size-capped API. Anything with the user's filesystem access is a writer.

What makes it worth blocking rather than shrugging at is the blast radius: /queue reads every pending file, so one oversized record does not just fail itself — it takes the route down for all of them. Note this is genuinely distinct from the thread cap added last round: that bounds growth through the API, and says nothing about a file written directly to disk.

_read_request now stats before reading and treats over-MAX_BODY_BYTES as unreadable, which is the same disposition the function already gives malformed JSON, so no caller learns a new failure mode.

Tests: TestQueueReadIsSizeBounded — an oversized but valid JSON record reads as absent (size alone refuses it), a normal record still reads, and a structural check that the size check precedes the read, since statting after loading the bytes would not prevent the exhaustion it exists to prevent.

2. server.py:3541 — removing a project leaks its running resources → FIXED.

Confirmed. _h_projects_remove dropped the registry row and saved, but the row is the only handle to what that project started: its child dev-server process, that process's injecting proxy, and its own dedicated static listener (each project gets its own, deliberately — a shared one would put two projects on one browser-storage origin). _stop_dev_proc already existed and was simply never called here; there was no teardown for _STATIC_SRV at all, so I added _stop_static_preview, shaped like the existing _stop_inject_proxy.

daemon_threads = True only helps at process exit, so on a long-lived gateway each add/preview/remove cycle stranded a process, a thread and a bound port.

One implementation detail that is load-bearing and not obvious from the diff: the teardown runs outside _QUEUE_LOCK. _stop_dev_proc escalates SIGTERM→SIGKILL and waits, and shutdown() blocks on the accept loop, so holding the registry lock across either would stall every other queue and registry operation for the length of a process kill. The registry mutation stays inside the lock; only the stopping moved out. There is a test pinning that ordering, because it is exactly the kind of thing a later refactor would "tidy" back inside the with block.

Tests: TestRemovingAProjectReleasesItsResources (both stops invoked with the removed id; the lock-ordering guard) and TestStopStaticPreview (pops and shuts down; unknown project is a no-op; a listener whose shutdown() throws is still forgotten, so one dying listener cannot make the remove itself fail).


Gates on 603e463ae: design-tweak backend suite 522 passed; per-file coverage still 85% with check_per_file_coverage.py --floor 80 exiting 0; isort / flake8 / mypy / tsc / eslint / brand-name / i18n:check clean.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against 603e463ae47742978a77e93a0c9acaa7812d79d3 → now b66160279. Fixed, and worth saying plainly: this finding is a defect the previous round introduced.

server.py:3075 — queue writes can create records the app refuses to read → FIXED.

Correct, and it is the mirror image of the read bound added last round. Each inbound payload is under MAX_BODY_BYTES, but a record accumulates payloads over its life — up to MAX_DRAFT_COMMENTS comments, each carrying a thread of up to MAX_THREAD_ENTRIES — so the accumulation can cross a ceiling no per-payload cap can see. Once it does, _read_request returns None, /queue stops listing the record, and the user's queued work is gone from the UI with the file still sitting on disk. A read guard that strands a draft is worse than the memory exhaustion it was added to prevent, which is precisely the failure mode I should have checked when adding it.

Fixed as an invariant rather than a second number: MAX_RECORD_BYTES now gates both sides, so what the writer accepts is what the reader returns, and the two cannot drift. It is deliberately a separate constant from MAX_BODY_BYTES even though the values are equal today — one bounds a single inbound payload, the other bounds a record's whole life, and conflating them would let a future change to the inbound cap silently move the record ceiling.

Two implementation choices that go beyond the reported fix:

  • The bound sits at the write chokepoint, not at /submit. The report named comments.append / _write_request in the submit path, but all five mutating routes funnel through _write_request/thread, seal-on-send, delete-comment and delivered can each be the append that crosses the line. Guarding only submit would have left four routes able to strand a draft.
  • The check runs inside _atomic_write_json, on the exact bytes it is about to write, and raises before the temp file exists. Checking a re-serialization in the caller would let the two drift if the writer's indent ever changed, and raising after the temp file was created risks os.replace swapping something over good state. The refusal therefore leaves the previous record byte-for-byte intact, which is the whole point. _save_cfg stays unbounded — the ceiling is about queue records, and a test pins that the config writer remains generic.

Surfaced at all six transaction boundaries as 413 with code: record_too_large, so a caller learns the draft is full rather than seeing a generic failure.

Tests: TestWhatIsWrittenStaysReadable — both ceilings resolve to the same constant; an oversized write is refused; a refused write leaves the previous record readable (the actual regression); a round-trip that grows a record until the writer refuses and then proves the last accepted record is still readable, so an off-by-one cannot hide between the two bounds; and structural checks that the bound is at the chokepoint and that config writes are untouched. 5 of the 6 fail with the fix reverted.


Gates on b66160279: design-tweak backend suite 528 passed; per-file coverage 85% with the gate exiting 0; isort / flake8 / mypy / tsc / brand-name / i18n:check clean.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against b66160279317cae71ca60c5929af0b12a0e85509. REBUTTED as framed — no code change, and escalating to the maintainer rather than patching, because this is a recurrence and its proposed remedy is a product decision.

server.py:1893 — project dev scripts inherit credential and governance access.

This is the same finding, on the same span, with the same proposed remedy ("remove automatic project-script spawning") that was already raised and adjudicated earlier in this PR's review history. A second model reviewing the same SHA ruled it a false positive as framed. Re-stating the evidence rather than re-litigating it:

  • The env-based mechanism is closed. _child_env (server.py:2145) is a deny-by-prefix strip removing KIROCREW_PROXY_SECRET, PORT, NODE_OPTIONS, SSH_AUTH_SOCK, GIT_SSH_COMMAND, GIT_SSH, and every KIROCREW_* / KIRO_CREW_* variable. No credential or capability variable crosses that seam, and the prefix strip is forward-compatible so a var added upstream later cannot leak through by default.
  • The residual filesystem reach is the accepted trust model, not a boundary this diff crosses. The child is the user's own project npm run dev, chosen by the operator through the UI. It is declared in test/test_spawn_audit.py (_start_dev_proc, with the rationale documented at line 51 of that file) alongside the existing precedent for operator-selected spawns. The AUTOSDE sandbox rule targets agent-selected subprocesses; this one is user-selected and identical in privilege to the operator running the same script in their own terminal.
  • The proposed fix would delete the feature. "Require an operator-started dev server" removes automatic dev-server start, which is one of the app's two primary capabilities. That is a scope/product change, not a defect fix, and it is not mine to make unilaterally.

The one genuinely new angle, stated honestly: this round adds "rewrites governance files → policy tampering," which the earlier rounds did not. At the OS level that is true — a child running as the user can write ~/.kiro/crew/security_policy.json, because is_sensitive_path gates Kiro Crew's own readers and writers and cannot constrain an arbitrary subprocess. But it is equally true of any command the user runs in their own shell, so it is not a boundary this PR introduces.

What is a fair question, and the reason this goes to the maintainer: whether an app-initiated spawn should be held to a stricter standard than a user-typed one, even when the operator picked the target. If the answer is yes, the fix is not in this app — it is sandboxing the dev-server spawn (a platform/-level change), and it should land as its own PR with its own design, not be bolted on here.

Two ways forward, maintainer's call:

  1. Accept the trust model as-is and clear the lane with a writer override:
    /ai-review override gpt b66160279317cae71ca60c5929af0b12a0e85509: operator-selected dev-server spawn; _child_env strips all credential and capability vars and the spawn is declared in test_spawn_audit.py
    (that must be the first line of the comment — the workflow parses it with startsWith)
  2. Decide the spawn must be sandboxed, in which case this app ships without automatic dev-server start and the sandboxed spawn lands separately.

I am deliberately not posting the override myself: it asserts a maintainer judgment about the app's threat model, and nobody has asked me to make that call.


Round count, for context on why this is escalating rather than iterating. This is the 11th review round on this PR. The last three rounds each produced legitimate findings that were fixed (symlink-swap read, unbounded thread growth, resend button state, unbounded queue read, project-removal resource leak, and a write/read ceiling mismatch the round before it introduced). This round produced a repeat of an already-rebutted finding whose remedy is a feature deletion. That is the signal to stop patching: the remaining disagreement is about the app's threat model, not about a defect.

Everything else on this head is green: design-tweak backend suite 528 passed, per-file coverage 85% against an 80% floor, and isort / flake8 / mypy / tsc / eslint / brand-name / i18n:check all clean.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Rebased onto latest main and pushed 607627273. Two things this round, one fixed and one newly-diagnosed that needs a decision.

Fixed — Backend Tests (Windows) (2): five of my own tests were POSIX-only.

The test files added for the coverage work made platform assumptions that only showed up on the Windows shard:

  • TestChildEnv::test_toolchain_bin_prepended_to_path and test_path_deduplication compared PATH entries against POSIX literals (/opt/homebrew/bin), but _child_env builds PATH from Path objects, so the separator is the host's — Windows produced \opt\homebrew\bin. Now compared through str(Path(...)) and joined with os.pathsep, which keeps the assertion meaningful on both platforms rather than skipping it.
  • TestChildEnv::test_preserves_ordinary_vars used patch.dict(..., clear=True), which removes HOME/USERPROFILE; _node_bin_dirs then hit Path.home() and raised RuntimeError: Could not determine home directory. _node_bin_dirs is now stubbed for that test, which is about which variables survive the strip and never needed real node paths.
  • The three TestInProcTree::test_posix_pgid_* tests patch os.getpgid, which does not exist on Windows, so patch() raised AttributeError instead of exercising anything. Now gated with a posix_only marker built on platform_compat.IS_POSIX. The Windows branch of _in_proc_tree (the parent-chain walk) already has its own tests, so neither platform's behaviour goes uncovered.

The sixth Windows failure, test_knowledge_sync_local_file.py::TestSyncLocalFile::test_sync_failed_ingest_marks_error, is not in this diff.

Newly diagnosed — Coverage Gate is failing on the FRONTEND per-file floor, not the backend one.

I owe a correction here. I previously reported this gate as fixed after taking server.py from 58% to 85% and confirming check_per_file_coverage.py exited 0. That check was the backend report only; I never ran the frontend one, so I called a gate green from half its evidence. The gate's actual output names three files:

  1.8%  src/apps/design-tweak/DesignTweakPage.tsx  (10/556)
 27.8%  src/apps/design-tweak/api.ts               (25/90)
 33.3%  src/apps/design-tweak/prompts.ts           (3/9)

Backend coverage is 90.38% and frontend 90.32% project-wide, so the project floors pass; this is purely the per-file floor on the three new frontend files, none of which is baselined (correctly — they are new, so they have to meet 80% outright).

prompts.ts and api.ts are small and straightforward. DesignTweakPage.tsx is not: 556 statements at 1.8% means roughly 435 more statements to cover on a React page that owns the preview iframe, the postMessage bridge to the injected overlay, the dev-server controls and the request rail. That is comparable in size to the backend coverage effort, which took five parallel agents.

I am flagging it rather than starting it, because it is a real chunk of work and the maintainer may prefer a different route — baselining the page and covering the two small files, splitting the page into testable units first, or landing the coverage as its own follow-up.

Everything else on 607627273 is green locally: design-tweak backend suite 528 passed, backend per-file coverage 85%, and isort / flake8 / mypy / tsc / eslint / brand-name / i18n:check clean.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Frontend per-file coverage is fixed. Pushed ddf095bfe.

All three files that the Coverage Gate flagged now clear the 80% floor, verified with the repo's own gate against a full-suite cobertura report rather than my own arithmetic:

file before after
src/apps/design-tweak/DesignTweakPage.tsx 1.8% (10/556) 86.22%
src/apps/design-tweak/api.ts 27.8% (25/90) 98.46%
src/apps/design-tweak/prompts.ts 33.3% (3/9) 100%

delivery.ts also reads 100%. check_per_file_coverage.py --label Frontend --floor 80 no longer names any design-tweak file.

Why the page was at 1.8% despite having tests. The three pre-existing designTweak*.test.ts files assert on the component's source text — they readFileSync the .tsx and match it with regexes. That is a useful drift guard but produces zero runtime coverage, because the component never renders. The fix was six new test files that actually mount the page with renderWithProviders and drive it, split by surface so they cover disjoint code:

  • DesignTweakProjectRailCov80 — project list, add/remove, folder picker, empty state
  • DesignTweakPreviewCov80 — iframe src derivation, dimensions menu, dev-server controls
  • DesignTweakRequestsCov80 — comment rows, submit/delete, send, resend and the delivery verdicts
  • DesignTweakOverlayBridgeCov80 — the host↔overlay postMessage bridge, including that a message from the wrong origin is ignored (the overlay runs inside a previewed project, so accepting one would let arbitrary page content drive the panel)
  • DesignTweakHistoryCov80 — history/archive, clear, delete, loading and backend-unreachable states
  • DesignTweakApiCov80api.ts and prompts.ts called directly

210 tests across the nine design-tweak files, all passing.

Two problems found while integrating, both worth recording because neither showed up in isolation:

Fake timers versus waitFor. The history file froze the clock with vi.useFakeTimers({ now }) so timeAgo labels would be stable. That silently broke every async test in the file — Testing Library's waitFor polls on real timers, so with them fully faked it never gets a tick and each test sat until the 15s cap. Fixed with shouldAdvanceTime: true, which keeps the mocked now for the component while letting the polling loop run. The comment at that line says so, because it reads like a tuning knob and is not.

Radix opens on pointerdown. The overflow-menu tests used fireEvent.click on the trigger, which leaves a Radix DropdownMenu closed, so every item lookup failed with "unable to find text". Now routed through a small openMenu helper using the pattern already established in WebPreviewPanel.test.tsx.

One test removed rather than left broken. A collapse/expand test would not drive the row toggle under happy-dom. After four attempts against the same mechanism I stopped and deleted it, leaving a comment saying why: the visibility mechanism it asserted is already covered by renders history requests collapsed by default in the same file, so nothing lost coverage. Weakening an assertion to make it pass would have been worse than removing it.

Three files the gate still flags, none of them in this diff (confirmed against git diff origin/main...HEAD --name-only): CommentsSidebar.tsx, CommentThreadPopover.tsx and SkillContextBudget.tsx. The first two are an artifact of how I measured locally — this host has one pre-existing failure in ArtifactDetailPageCoverage.test.tsx (fails identically on a pristine main in isolation), vitest writes no coverage report when a test fails, so I excluded that file to get a report at all. ArtifactDetailPage.tsx imports and renders both components, so excluding its test is exactly what dropped them. In CI that file runs, so they keep their coverage.

Gates on ddf095bfe: frontend suite 20,404 passed / 1,269 files (with the one pre-existing failure excluded), design-tweak 210 passed, frontend per-file gate clear of every file in this diff, and tsc / eslint / i18n:check / vite build green. Rebased onto latest main.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against ddf095bfedb574bd3e2f9c1c4e8de630b094e940. Two of the three findings CONFIRMED and FIXED in 05502e53d. The third is acknowledged and deliberately deferred — called out at the bottom.

1. DesignTweakPage.tsx:102 — slot-key collisions merge distinct projects. Confirmed, fixed.

Reproduced the exact pair from the finding: /tmp/project-1n and /tmp/project-30 both hashed to 19gozug. The cause is structural rather than probabilistic — the fold is h * 31 + c, and '1' * 31 + 'n' equals '3' * 31 + '0', so any two paths differing only in their last two characters can collide directly, with no adversary and no birthday luck. Since the key is the session's identity, a collision merges two projects' turns, seeds and session scope into whichever arrived first.

Replaced with a 128-bit digest: FNV-1a over four lanes with distinct offset bases, base36 per lane. Both bytes of every UTF-16 code unit are mixed, so /a/Ā cannot fold onto /a/\0 either. Deliberately not crypto.subtle.digest — it is async and this runs inside the synchronous key derivation on the render path.

The regression test asserts the documented pair separates, and then pins the whole structural class: all 1,296 paths differing only in their last two characters yield 1,296 distinct keys.

2. api.ts:291 — delivery verification discards closed-session history. Confirmed, fixed — and the suggested fix needed careful ordering to actually work.

Traced it end to end before changing anything. get_or_create_slot (state.py:4347) does adopt an existing slot by name, so the POST is idempotent while the tab is open. But closing a tab runs state._slots.pop(name, None) (chat_handlers.py:2579) while the JSONL history stays on disk, and the detail GET serves existing.messages (chat_handlers.py:3670) — the in-memory list. So for a closed session the POST returns a brand-new empty slot and the read reports zero entries: the batch looks undelivered and the resend applies every edit a second time.

The finding's remedy is right, but the order is the whole fix, and the obvious reading of it is inert: POST .../resume short-circuits when the slot already exists (chat_handlers.py:3646) and returns existing.messages without touching the disk. So resuming after creating the slot hands resume the empty slot that was just created, and it faithfully reports nothing — creating the slot is precisely what hides the history. My first attempt did exactly this and the regression test caught it.

Resume is now the first call, before any creation, and doubles as the read since it returns the same {messages, queue} shape. It has its own catch because chatApi throws on any non-2xx and resume legitimately refuses with 400 no conversation log; on that path the code still falls back to adopt-or-create so a genuinely fresh slot reads as an empty transcript rather than as "unknown". Both paths are pinned by tests, and the first one is mutation-verified: removing the resume call fails it.

3. server.py:3002 — shallow selection validation. Confirmed real, deferred by maintainer decision.

Not disputing it. Verified the mechanism: _el_name does name = el.get("tag", "") then name += f"#{el['id']}", so a non-string tag raises TypeError, and because the record is already persisted every subsequent /queue poll raises — one bad submit for a lasting queue outage. classes has the same exposure through ".".join(...).

It is being held for a follow-up rather than folded in here, to keep this diff to the two client-side defects. Flagging plainly that this is a deliberate deferral of a real defect, not a rebuttal, so the next review round sees it as known.

Local gates on 05502e53d: design-tweak frontend 214 passed, tsc -b, eslint --max-warnings 1116, i18n:check green, and the per-file coverage floor still clears on the touched files (DesignTweakPage.tsx 86.92%, api.ts 98.59%). Rebased onto latest main.

One thing worth a separate issue, outside this diff: website/src/app-sdk/useChatSession.ts:15 (hashStr) is the same 32-bit fold used for the same purpose — deriving a slot key from a workspace path — for every app built on the SDK. It carries the identical collision class. Not changed here because it is pre-existing shared code well outside this PR's scope.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Follow-up on the third finding, now fixed in a89b339a6 — the earlier comment deferred it, so this supersedes that.

server.py:3002 — shallow selection validation permits persistent queue failures. Confirmed and fixed on both sides.

Reproduced the mechanism rather than taking it on the label. _el_name did name = el.get("tag", "") then name += f"#{el['id']}", so {"tag": 42, "id": "x"} raised TypeError: unsupported operand type(s) for +=: 'int' and 'str'. classes had the same exposure through ".".join(...). The existing guard only checked isinstance(el, dict), which a malformed field passes untouched.

Fixed as a read/write pair, because a boundary check alone is not sufficient here:

  • Write side (_h_submit) rejects non-string tag/id and a classes that is not a list of strings, with 400 selection_malformed — matching the code the adjacent element-shape guard already returns. This is the layer that gives the caller a real error instead of silently relabelling its input.
  • Read side (_el_name) is now total: every field is coerced, and non-string classes entries are dropped rather than coerced (a coerced one would print as .42). This is the layer that matters for a record already on disk — a queue file written by an older build cannot be healed by a new boundary check, and a raise on that path is not a bad label, it is a permanently 500-ing /queue, because the poll cannot skip the offending record and the whole request list stays unreadable until the file is deleted by hand.

That pairing is deliberate and it is the same shape as the MAX_RECORD_BYTES fix earlier in this PR, where gating only the write left a record the reader still refused. One-sided validation is what turned that round into two.

Both layers are mutation-verified independently: removing the boundary guard fails test_a_non_string_tag_is_refused and test_a_non_string_id_or_classes_is_refused; restoring the original _el_name fails the three test_summarize_tolerates_* tests, which is GPT's TypeError reproduced directly. There is also a test pinning that the shapes the preview legitimately sends still pass — id and classes are both optional and an empty classes list is normal for an element selected by tag alone, so a guard that rejected absence or emptiness would have broken ordinary use.

Gates on a89b339a6: 534 design-tweak backend tests pass, server.py per-file coverage holds at 85%, and isort / flake8 / mypy are clean. Rebased onto latest main.

All three findings from this round are now closed. The app-sdk/useChatSession.ts hashStr twin noted in the previous comment remains untouched and out of scope — it is pre-existing shared code, and worth its own issue rather than a widening of this diff.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against a89b339a60d6fc1e119ea3cf3c5215985025af7d. Defect CONFIRMED and FIXED in 431701275. The proposed remedy is declined — it rests on a premise that is not true of this codebase.

api.ts:315 — a truncated transcript can trigger duplicate edits.

The mechanism is real and I am not disputing it. POST .../resume returns slot.messages[-200:] (chat_handlers.py:3859), and deliveryVerdict treated "id not found" as missing, which is the verdict that drives the resend. A request older than the window is absent from a transcript it was genuinely delivered into, so the resend reapplies every completed edit.

Why the suggested fix is not the one I applied. "If resumed.has_more, fetch the unpaginated slot detail" — there is no unpaginated slot detail. The detail GET applies the same 200-row cap (chat_handlers.py:3672, existing.messages[-200:]), so switching to it changes the window not at all. resumed.has_more does exist (chat_handlers.py:3873) and the finding is right about that field; it is the destination that does not. Following the suggestion literally would have produced a no-op fix with passing tests — the same failure mode as the previous round, where resume had to precede slot creation or it silently returned nothing.

What I did instead, in 3 lines of behaviour: return unknown rather than missing when the search window was incomplete.

if (transcriptHasRequest(t, req.id)) return 'delivered'
return t.hasMore ? 'unknown' : 'missing'

unknown already exists in this enum and already means exactly this: it neither acks nor resends, and the request is re-examined on the next pass. Absence of evidence is not evidence of absence, and the only thing the caller actually needs to stop corrupting data is to distinguish "searched everything, not there" from "could not see that far back". Paging the full history into the client to answer the same question would be strictly more machinery for a strictly worse outcome — unbounded transfer to reach a verdict that a single flag settles.

has_more is threaded through both read paths (resume, and the create+detail fallback), each of which parses its own response.

Mutation-verified in both halves, because either one alone fails silently:

  • Reverting the verdict guard to return 'missing' fails reports unknown — NOT missing — when the window is truncated.
  • Breaking the wiring to read camelCase hasMore off the wire (the server sends snake_case has_more) fails carries has_more through the resume path. This is the failure I most wanted a test for: the guard would read undefined, never fire, and the duplicate-resend bug would be back with every other test still green.

Two tests guard the obvious over-correction: missing is still returned for a complete transcript (or the retry path this verdict drives would never fire at all), and a truncated window that does contain the batch still reads delivered — truncation only clouds absence.

Gates on 431701275: 220 design-tweak frontend tests pass, delivery.ts at 100% statements, api.ts 98.59%, DesignTweakPage.tsx 86.92% (all above the per-file floor), and tsc -b / eslint --max-warnings 1116 / i18n:check are green. Rebased onto latest main.

A process note, offered rather than asked for. This is round 14 on this PR, and this finding was created by the fix in round 13 — which was itself created by the fix in round 10. Each has been real and each has been narrower than the last. That is a healthy trend, but the remaining items are now bounded, reachable only through multi-step operator sequences, and confined to one client-side verification helper whose worst case is a duplicated edit the user can see and undo. I would rather land this and open a follow-up for anything further than keep the diff open across another rebase of a fast-moving main.

@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against 4317012757087a356a7a6958a8109fef955e045b. CONFIRMED and FIXED in 42e2cd654, exactly as prescribed.

server.py:3500 — dev-server stop saves registry state without serialization.

Verified against the file's own invariant rather than reasoned about in the abstract. Every other _save_cfg(_CFG) call site — 3537, 3570, 3635, 3659, 3678 — is inside with _QUEUE_LOCK, and the one at 3627 even carries the comment # read-modify-write over the shared registry. _next_number's legacy write (line 363) is also covered, reached only from _txn() at 3078 which is called at 3167 inside the lock at 3165. _h_dev_server_stop was the only unguarded one in the file. Since _save_cfg is a whole-file atomic replace, two concurrent replacements do not interleave — the loser is overwritten wholesale, so a project added or removed by the racing request disappears on the next load.

The finding's fix is right, including the part that says to keep _stop_dev_proc outside the lock — that placement is deliberate (it escalates SIGTERM→SIGKILL and waits on the child, so holding _QUEUE_LOCK across it would stall every queue operation for the length of the teardown) and it is preserved.

One thing added beyond the prescription. proj is resolved before the teardown, which is a long window — a SIGKILL escalation. Mutating that pre-lock dict afterwards would persist a registry the project is no longer part of, resurrecting a concurrently-removed project. So the handler re-resolves inside the lock and saves nothing when the project is gone. Without this, the fix would close the lost-update race and leave a resurrection race in its place.

Both halves of the ordering are mutation-verified, because each fails silently on its own:

  • Removing the lock (restoring the original two lines) fails test_stop_saves_the_registry_under_the_lock and test_stop_does_not_resurrect_a_concurrently_removed_project.
  • Moving _stop_dev_proc inside the lock fails the same first test on its other assertion — process teardown must NOT hold the lock.

The test observes lock state by substituting a depth-tracking context manager for _QUEUE_LOCK, not by inspecting it: it is an RLock, which has no .locked(), and being reentrant it grants a same-thread acquire(blocking=False) even while held, so neither of the obvious probes can answer "is it held right now".

Gates on 42e2cd654: 536 design-tweak backend tests pass (up from 534), isort / flake8 / mypy clean. Rebased onto latest main.

Round 15, and I want to be straight about the pattern rather than just keep patching. Five consecutive rounds have each produced one real, narrow, correctly-reported defect in a different corner of this app — read/write ceiling pairing, resume ordering, element field types, transcript truncation, and now registry serialization. None has been a false positive and none has recurred, which says the review is working; but they are being found one per round in a 3,700-line file, and the diff has now been rebased across a fast-moving main seven times. The remaining risk surface is uniformly "concurrent multi-step operator sequence, worst case a visible and recoverable data loss in a default-disabled builtin app". My recommendation is unchanged from the last round: land this and take further findings as follow-up issues against the merged code, where they can be fixed without re-arming eleven review bots and re-racing main each time.

The "Design Tweak" visual select-to-edit tool only existed as an
externally-installed app from a separate repo. Graduates it to a
first-party builtin so it's discoverable in the App Store, versioned with
the gateway, and held to the repo's own quality gates: a compiled React UI
page (website/src/apps/design-tweak/) plus a gateway-managed subprocess
backend (src/kiro_crew/apps/builtins/design_tweak/backend/server.py)
behind an HMAC-verified proxy.

Security properties of the backend: every filesystem path resolves through
one canonical _contained() barrier, the dev proxy re-validates its loopback
allowlist at the sink, upstream response headers are sanitised before echo,
and spawned dev scripts inherit neither the proxy HMAC secret, PORT, nor
SSH credentials (SSH_AUTH_SOCK / GIT_SSH_COMMAND / GIT_SSH) -- an untrusted
project dev script must not be able to authenticate as the operator. Each
previewed project gets its OWN loopback listener, so two projects never
share a browser-storage origin: same-origin is scheme+host+port, and a
shared listener differentiated only by URL path would have let project B's
page read project A's localStorage. GET /latest applies the same
_redact_thread() floor /queue and /history apply, so agent-written
credential text in the queue JSON cannot be served verbatim.

NO automatic takeover of the external install. An earlier revision of this
branch carried ~1167 lines that renamed a user-installed app directory
during gateway startup and moved its data/ forward. That is dropped
deliberately: register_builtin_apps() already stands down on a directory it
did not write (_builtin_owns_install(), pre-existing), and uninstall_app()
already keeps data/ by default -- so a user coming from the external app
uninstalls it once and the builtin registers into that same directory on
the next start, inheriting the preserved queue and history. Automating that
bought two clicks for the few users who have the external app, at the cost
of moving user-owned directories at startup for everyone -- a failure
surface (crash-interrupted renames, symlinked data/, enabled-state
carry-over) far larger than the convenience. The path is documented in the
app's README instead.

Tests: full frontend vitest (19906) and the design_tweak backend suite pass;
isort, flake8, mypy, tsc -b, eslint at the repo baseline, brand-name gate,
and I18N_BASE_REF=origin/main i18n-check all clean.
@iamwhatever

Copy link
Copy Markdown
Collaborator

Disposition against 42e2cd6541de0fbf377e4f76e1d4e975ca2f7784. CONFIRMED and FIXED in 5ef6f79b2.

server.py:499 — queue comments bypass output redaction.

Not a threat-model question, and I am not going to argue it: this is the app's own documented invariant applied to one field of a dict and missed on its sibling. In the same returned object, line 518 was "thread": _redact_thread(...) while line 499 returned "comment": c.get("comment", "") raw. And _redact_thread's docstring, twenty lines above, already makes the exact argument the finding makes:

_h_thread already redacts before writing, so nothing new should reach disk unredacted — but ingest is not the only writer. The delivery model hands the agent the queue JSON directly (that is how a request reaches it), so an entry written into the file rather than posted to /thread … would otherwise be rendered verbatim in the panel. Redaction is an always-on floor in this repo, so it belongs on both edges.

Every word of that applies to the comment text. /submit redacts on ingest; the file is not only written by /submit.

Fixed by extracting the order into one helper rather than adding a second inline copy of the two calls. _redact_text() now owns redact_exfiltration_urlsredact_credentials, and both _redact_thread and the comment field call it. The order was already duplicated once; a third copy is how the edges drift, and the order is load-bearing — redact_exfiltration_urls keys off the host, so a credential-first pass rewrites the token inside the URL and leaves a live, fetchable exfiltration host standing.

I also checked that this closes the field set rather than one instance of it: everything else _summarize / _summarize_comment return is an id, a timestamp, a count, or a path (paths went through the containment barrier in an earlier round). comment was the last free-text field reaching the panel unredacted.

Mutation-verified: removing the comment redaction fails three of the new tests; flipping the order to credentials-first fails the ordering test, which now guards the shared helper for the thread path too.

Two of my own test assertions were wrong before they were right, and the corrections are worth recording because they are easy to get backwards:

  • The redaction tag names the host by design ([REDACTED: suspicious URL to evil.example.com]), so "the hostname string is absent" is the wrong assertion. The correct proof is that no fetchable https://host form survives.
  • A plain external URL is deliberately not redacted — only one carrying something worth exfiltrating is. Blanket-redacting external links would mangle an ordinary "see https://react.dev/…" note. There is now a test pinning that as intentional, so the next reader does not file it as a gap.

Gates on 5ef6f79b2: 541 design-tweak backend tests pass (up from 536), 220 frontend, isort / flake8 / mypy clean. Rebased onto latest main.


Escalation, per the rule I set for myself last round.

Rounds 11 through 16 have now each produced exactly one new, real, correctly-reported defect, each in a different corner of this app: record-size read/write pairing, resume ordering, element field types, transcript truncation, registry serialization, and now output redaction. Zero false positives, zero recurrences. The review is genuinely working — and it is also not converging, because the arrival rate has not fallen: one per round, six rounds running, in a 3,700-line file, with a main rebase between each.

Two of those six rounds also shipped a suggested fix that would have been inert if applied literally (round 13's create-then-resume, round 14's "unpaginated detail"), which is the part that makes further rounds expensive rather than merely slow: each one needs full source verification before it can be trusted, and one round's fix has twice become the next round's finding.

The remaining surface is uniformly: concurrent or multi-step operator sequences, in a default-disabled builtin app, worst case a visible and recoverable data loss or a redaction gap on the user's own screen. I am recommending this lands now and that anything further is taken as follow-up issues against merged code, where a fix costs one small PR instead of re-arming eleven review bots and re-racing main. I am not going to keep patching round-by-round without saying that out loud.

Also still open and deliberately out of scope, worth its own issue: website/src/app-sdk/useChatSession.ts:15 (hashStr) is the same 32-bit fold that round 12 replaced here, used for the same purpose — deriving a chat-slot key from a workspace path — by every app built on the SDK.

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

Labels

needs-author-decision PR blocked on author input posix-only-approved Cross-Platform Portability findings reviewed and accepted as intentionally POSIX-only

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants