fix(computer-use): close two approval bypasses and six correctness bugs - #831
Conversation
UX Review (Fable 5) — ✅ PASSAdvisory UX-level review of UX-Verdict: PASS No UI change ships; every touched string improves the experience — the inescapable "no element at that index" loop and the silent screenshot suppression both now explain themselves and name the remedy. The three model-facing surfaces this PR edits all pass the cold-read test: [UX-REVIEWED] 34f4d7c |
Arbiter — ✅ no blocking findingsArbiter found no unresolved long-term items that require action before merging Second-order review for Review detailsBoth files read. The line-level reviewers (Opus 5, GPT 5.6) reported no findings, and the UX lane passed — so the only sub-threshold material to arbitrate is the design reviewer's two Watch items and one suggestion. I traced the design reviewer's dead-branch claim against the actual The claim is textually correct: Arbiter-Verdict: PASS No sub-threshold finding meets the long-term-impact bar. Suggested follow-ups (open as issues — non-blocking)
[ARBITER-REVIEWED] 34f4d7c False positive or not applicable? A repository writer can comment: For a broader accepted-risk deferral, apply |
Design Review (Fable 5) — 🟡 CONCERNSAdvisory design-level review of Design-Verdict: CONCERNS The generic kind=read branch returns before the computer-use branch, making the CU gate dead code and half-reopening the bypass this PR exists to close. Watch
Suggestions
[DESIGN-REVIEWED] 34f4d7c |
GPT 5.6 Review — ✅ no blocking findingsGPT 5.6 completed its review of This comment is updated in place on each push. Review detailsNo findings. False positive or not applicable? A repository writer can comment: |
cd836ca to
4d01cb1
Compare
|
Disposition for
The denylist was the wrong shape and the finding is right about why: Inverted to an allow-list: only
Local gates on |
Opus 5 Review — ✅ no blocking findingsReviewed Verdict parsed from the review's SHA-scoped output markers for commit False positive or not applicable? A repository writer can comment: |
Follow-up to #644, from a max-effort review of the merged diff plus three mirrored reviewer passes. Every finding was reproduced before it was fixed and each regression test was verified to fail on the pre-fix code. Two approval/authorization bypasses: - hooks.py tested the computer-use read-only auto-approve BEFORE any tool_kind guard. `tool_name` is the display title and select_tool_title prefers the LLM-authored `description`, so once the operator enabled computer use, a mutating call titled `mcp__kirocrew-computer__computer_get_state` skipped interactive approval entirely (verified for all six mutating kinds). The semantic tool_kind is now authoritative and is tested before every title-keyed fast-path — as an ALLOW-list on _READ_ONLY_TOOL_KINDS, not a denylist of mutating kinds: tool_kind arrives verbatim from the ACP `kind` field, so an unenumerated but real value like "other" defeated the first (denylist) fix. - PUT /api/computer-use/config had no caller check, and request["user"] is truthy for an App-Kit token too. An app declaring permissions.api: ["/api/computer-use"] satisfies app_token_path_allowed, and enable_state.save_state deliberately bypasses is_sensitive_path — so an agent able to author an app manifest could mint a token and flip `enabled: true` on its own desktop automation. Now 403 before the body is read, audited. Correctness: - K_CG_EVENT_SOURCE_STATE_PRIVATE was 1; CGEventTypes.h declares kCGEventSourceStatePrivate = -1 and 1 is kCGEventSourceStateHIDSystemState, so every synthesized event came from the shared HID table the module's own hazard note exists to avoid. The old test pinned the constant against itself; it now asserts the literal. - The drift-verification AND post-action refresh walks used the config default tree budget, so an element the model was shown at max_tree_nodes=2001 became permanently un-actionable in a loop re-snapshotting could not break. Snapshot.walk_budget carries the budget the walk actually used. - tools._element_payload/_element_from_payload round-tripped 9 of ElementRec's 12 fields, silently deleting frame, traits and focused — #644's headline accessibility reads — on the only path a model reaches. - macos_ffi.post_text encoded per character inside the posting loop, so a lone surrogate raised after part of the string had been typed into a live application. The whole string is encoded up front. - overlay.get_shared_overlay skipped its lock on the premise that callers live on the event loop; its only caller is sync and runs on the 8-worker subprocess_executor, so two concurrent pointer clicks built two overlays and leaked a child process. - A truncated walk's screenshot suppression was silent — routine for any Chromium/Electron window at the 1200-node default — and the shipped fake attached pixels there, so deleting the production branch left CI green. - security_posture omitted MCP_COMPUTER_SCHEMAS, hiding all ten computer-use tools from the report (63 -> 73); the drift test written to catch that hardcoded the same two registries and is now self-maintaining. Docs, comments and shipped strings that outlived the removed governance model: the deleted allow_pointer_move flag (including a help string served to the dashboard), a 409 and a read_only field neither of which exists, a nonexistent approval-floor clamp, bundle_id/cu_action as governance matchers (naming either aborts governance boot), and operator advice to narrow computer_use.apps. SKILL.md's only worked example called computer_press_key twice without element_index — a script the code refuses. Also deletes gate.targets_axis_is_governed, which had no caller and a docstring asserting the inverse of an enforced control.
4d01cb1 to
34f4d7c
Compare
|
Disposition for
Correct, and thanks — this is the same class as the last round one level deeper. Two agent-controlled inputs met in that branch and either alone was sufficient: the title (from Fixed by requiring both to agree — the computer-use auto-approve is now reached only under the One deliberate narrowing from your prescribed fix, stated so it can be challenged: I did not return Also in this push: removed an orphaned Local gates on |
…gs (#831) Follow-up to #644, from a max-effort review of the merged diff plus three mirrored reviewer passes. Every finding was reproduced before it was fixed and each regression test was verified to fail on the pre-fix code. Two approval/authorization bypasses: - hooks.py tested the computer-use read-only auto-approve BEFORE any tool_kind guard. `tool_name` is the display title and select_tool_title prefers the LLM-authored `description`, so once the operator enabled computer use, a mutating call titled `mcp__kirocrew-computer__computer_get_state` skipped interactive approval entirely (verified for all six mutating kinds). The semantic tool_kind is now authoritative and is tested before every title-keyed fast-path — as an ALLOW-list on _READ_ONLY_TOOL_KINDS, not a denylist of mutating kinds: tool_kind arrives verbatim from the ACP `kind` field, so an unenumerated but real value like "other" defeated the first (denylist) fix. - PUT /api/computer-use/config had no caller check, and request["user"] is truthy for an App-Kit token too. An app declaring permissions.api: ["/api/computer-use"] satisfies app_token_path_allowed, and enable_state.save_state deliberately bypasses is_sensitive_path — so an agent able to author an app manifest could mint a token and flip `enabled: true` on its own desktop automation. Now 403 before the body is read, audited. Correctness: - K_CG_EVENT_SOURCE_STATE_PRIVATE was 1; CGEventTypes.h declares kCGEventSourceStatePrivate = -1 and 1 is kCGEventSourceStateHIDSystemState, so every synthesized event came from the shared HID table the module's own hazard note exists to avoid. The old test pinned the constant against itself; it now asserts the literal. - The drift-verification AND post-action refresh walks used the config default tree budget, so an element the model was shown at max_tree_nodes=2001 became permanently un-actionable in a loop re-snapshotting could not break. Snapshot.walk_budget carries the budget the walk actually used. - tools._element_payload/_element_from_payload round-tripped 9 of ElementRec's 12 fields, silently deleting frame, traits and focused — #644's headline accessibility reads — on the only path a model reaches. - macos_ffi.post_text encoded per character inside the posting loop, so a lone surrogate raised after part of the string had been typed into a live application. The whole string is encoded up front. - overlay.get_shared_overlay skipped its lock on the premise that callers live on the event loop; its only caller is sync and runs on the 8-worker subprocess_executor, so two concurrent pointer clicks built two overlays and leaked a child process. - A truncated walk's screenshot suppression was silent — routine for any Chromium/Electron window at the 1200-node default — and the shipped fake attached pixels there, so deleting the production branch left CI green. - security_posture omitted MCP_COMPUTER_SCHEMAS, hiding all ten computer-use tools from the report (63 -> 73); the drift test written to catch that hardcoded the same two registries and is now self-maintaining. Docs, comments and shipped strings that outlived the removed governance model: the deleted allow_pointer_move flag (including a help string served to the dashboard), a 409 and a read_only field neither of which exists, a nonexistent approval-floor clamp, bundle_id/cu_action as governance matchers (naming either aborts governance boot), and operator advice to narrow computer_use.apps. SKILL.md's only worked example called computer_press_key twice without element_index — a script the code refuses. Also deletes gate.targets_axis_is_governed, which had no caller and a docstring asserting the inverse of an enforced control.
…gs (kirodotdev#831) Follow-up to kirodotdev#644, from a max-effort review of the merged diff plus three mirrored reviewer passes. Every finding was reproduced before it was fixed and each regression test was verified to fail on the pre-fix code. Two approval/authorization bypasses: - hooks.py tested the computer-use read-only auto-approve BEFORE any tool_kind guard. `tool_name` is the display title and select_tool_title prefers the LLM-authored `description`, so once the operator enabled computer use, a mutating call titled `mcp__kirocrew-computer__computer_get_state` skipped interactive approval entirely (verified for all six mutating kinds). The semantic tool_kind is now authoritative and is tested before every title-keyed fast-path — as an ALLOW-list on _READ_ONLY_TOOL_KINDS, not a denylist of mutating kinds: tool_kind arrives verbatim from the ACP `kind` field, so an unenumerated but real value like "other" defeated the first (denylist) fix. - PUT /api/computer-use/config had no caller check, and request["user"] is truthy for an App-Kit token too. An app declaring permissions.api: ["/api/computer-use"] satisfies app_token_path_allowed, and enable_state.save_state deliberately bypasses is_sensitive_path — so an agent able to author an app manifest could mint a token and flip `enabled: true` on its own desktop automation. Now 403 before the body is read, audited. Correctness: - K_CG_EVENT_SOURCE_STATE_PRIVATE was 1; CGEventTypes.h declares kCGEventSourceStatePrivate = -1 and 1 is kCGEventSourceStateHIDSystemState, so every synthesized event came from the shared HID table the module's own hazard note exists to avoid. The old test pinned the constant against itself; it now asserts the literal. - The drift-verification AND post-action refresh walks used the config default tree budget, so an element the model was shown at max_tree_nodes=2001 became permanently un-actionable in a loop re-snapshotting could not break. Snapshot.walk_budget carries the budget the walk actually used. - tools._element_payload/_element_from_payload round-tripped 9 of ElementRec's 12 fields, silently deleting frame, traits and focused — kirodotdev#644's headline accessibility reads — on the only path a model reaches. - macos_ffi.post_text encoded per character inside the posting loop, so a lone surrogate raised after part of the string had been typed into a live application. The whole string is encoded up front. - overlay.get_shared_overlay skipped its lock on the premise that callers live on the event loop; its only caller is sync and runs on the 8-worker subprocess_executor, so two concurrent pointer clicks built two overlays and leaked a child process. - A truncated walk's screenshot suppression was silent — routine for any Chromium/Electron window at the 1200-node default — and the shipped fake attached pixels there, so deleting the production branch left CI green. - security_posture omitted MCP_COMPUTER_SCHEMAS, hiding all ten computer-use tools from the report (63 -> 73); the drift test written to catch that hardcoded the same two registries and is now self-maintaining. Docs, comments and shipped strings that outlived the removed governance model: the deleted allow_pointer_move flag (including a help string served to the dashboard), a 409 and a read_only field neither of which exists, a nonexistent approval-floor clamp, bundle_id/cu_action as governance matchers (naming either aborts governance boot), and operator advice to narrow computer_use.apps. SKILL.md's only worked example called computer_press_key twice without element_index — a script the code refuses. Also deletes gate.targets_axis_is_governed, which had no caller and a docstring asserting the inverse of an enforced control.
Problem
A max-effort code review of the merged PR #644 (computer use) surfaced 15 verified findings, and three mirrored reviewer passes over the fix diff surfaced 11 more. Two were reachable authorization bypasses that defeat the feature's entire security model:
hooks.pytested the computer-use read-only auto-approve before the_WRITE_TOOL_KINDSguard.tool_nameis the display title, andselect_tool_title(acp/_dispatch.py) prefers the LLM-authoreddescription—on_tool_call's own docstring calls it untrusted for security decisions. So once the operator enabled computer use, anedit/execute/write/delete/move/createcall that titled itselfmcp__kirocrew-computer__computer_get_statewas auto-approved. Verified for all six kinds.PUT /api/computer-use/confighad no caller check at all.request["user"]is truthy for an App-Kit-scoped token too, an app declaringpermissions.api: ["/api/computer-use"]satisfiesapp_token_path_allowed(a bare prefix matches on the path boundary — verified), andenable_state.save_statedeliberately bypassesis_sensitive_pathso the operator's own panel can write a file the agent cannot. An agent able to author an app manifest could therefore read its own.app_secret, mint a token, and flipenabled: true.Plus six correctness defects and a large body of documentation that outlived the governance model #644 removed.
Why it matters
The keystone
computer_use.jsonis the whole security boundary for a feature that reads every window and synthesizes input into any application. The spec says so explicitly. Both bypasses route around it: one removes the human prompt that is the last thing standing between a prompt-injected agent and a click, the other lets the agent grant itself the capability. The correctness bugs are less severe but user-visible — one made a documented happy path (max_tree_nodesup to 5000, "raise it for dense apps") permanently unusable in a loop the model could not escape.Fix (symptoms → root cause → change)
Approval bypass. Symptom: a write-kind call auto-approved. Root cause: a title-keyed branch sat above the semantic-kind guard, and the title is agent-controlled. Change: the mutating-kind test is hoisted above every title-keyed fast-path and returns
allow(interactive approval). Equivalent for all prior cases — the oldkind not in _WRITE_TOOL_KINDS and not kindwas already redundant.Keystone write. Symptom: an app token writes the enable. Root cause: no
request["app"] == ""assertion, and the cookie check cannot separate an app from the operator. Change:403before the body is read, SEL-audited, matchinghandlers/kiro_prerequisite.pyandmessaging.py. The two machine routes already re-assertinternal_auth, which an app token can never satisfy.kCGEventSourceStatePrivate. Symptom: none visible. Root cause: the constant was1; Apple'sCGEventTypes.hdeclares it-1, and1iskCGEventSourceStateHIDSystemState— the shared table whose live modifier state produced the measuredabc→' I Abc'bug this source exists to avoid. Masked only because every path also callsCGEventSetFlags(event, 0). The old test asserted the constant against itself, so it could not catch a wrong value; it now asserts the literal.Element indices above the config default. Symptom:
computer_get_state(max_tree_nodes=2001)shows element 1400, the click is refused "no element at that index", and re-snapshotting reproduces it forever. Root cause: mutating tools take no budget arguments, so both the drift walk and the post-action refresh were built from the 1200 default. Change:Snapshot.walk_budgetcarries the budget the walk actually used. (Writing the third test exposed the refresh-walk half, which the review had not identified.)Lossy ceiling round-trip. Symptom: no rects, no
(editable), no focus line in anycomputer_get_state. Root cause:_element_payload/_element_from_payloadenumerated 9 ofElementRec's 12 fields. Change: all 12, plus a_frame_from_payloadthat rejects partial/bool/NaN/inf rects rather than emitting a plausible wrong rectangle.editableis the load-bearing loss — it is the only signal separating a writable field from a read-only one.post_textpartial typing. Symptom: "typing failed" while the app holds half the string. Root cause: per-character encode inside the posting loop; a lone surrogate raises mid-way. Change: encode up front, refuse having typed nothing.Overlay singleton race. Symptom: two fighting fake cursors and a leaked child process. Root cause:
get_shared_overlayskipped its lock claiming callers live on the event loop; its only caller is sync and runs on the 8-workersubprocess_executor. Change: athreading.Lock, matching every sibling singleton.Silent screenshot suppression. Symptom:
screenshot: trueon Chrome returns no image and no reason. Root cause:capture_macosrefuses on a truncated walk (it cannot prove the window holds no password field) but_render_image_notespecial-cased onlyhas_secure. Change:TRUNCATED_WINDOW_NOTE, naming the remedy — gated onwalk_budget.want_image, because an unconditional note inverted the bug and announced a suppression on every mutating action's refresh (caught by a reviewer on the first version of this fix).Security-posture blind spot. All ten computer-use schemas were missing from the report (63 → 73). The drift test written to catch that hardcoded the same two registries the implementation did; it now discovers them.
Doc drift. The deleted
allow_pointer_move(including a help string served to the dashboard), a409and aread_onlyfield neither of which exists in the module, a nonexistent approval-floor clamp,bundle_id/cu_actionas governance matchers (naming either raisesPlatformCompositionErrorand aborts governance boot), and operator advice to narrow acomputer_use.appsscope that was removed.SKILL.md's only worked example calledcomputer_press_keytwice withoutelement_index— a script the code refuses, shipped to every install.Also deletes
gate.targets_axis_is_governed: no caller, and a docstring asserting the inverse of an enforced control.Tests
Every regression test was verified to fail on the pre-fix code, not just pass on the fixed code.
test_hooks.py::TestMutatingKindBeatsTheTitletest_computer_use_api.py::TestAnAppTokenCannotWriteTheKeystonetest_computer_use_ffi.py::test_the_private_event_source_constant_is_the_value_apple_declares-1, not the symboltest_computer_use_ffi.py::test_post_text_types_NOTHING_when_a_character_cannot_be_encodedCGEventPostToPidcalls on an unencodable stringtest_mcp_computer.py::TestTheDriftWalkHonoursTheSnapshotBudgettest_computer_use_snapshot.py::TestTheCeilingRoundTripIsLosslessdataclasses.fields, so a NEW field fails rather than being droppedtest_computer_use_snapshot.py::TestASuppressedScreenshotAlwaysSaysSoWhywant_imageguard is dropped), incl. one case through realdispatch_tooltest_computer_use_overlay.py::test_the_shared_overlay_is_one_instance_under_thread_contentiontest_security_posture.py::test_every_mcp_schema_registry_is_covered_by_the_posture_viewtest_mcp_computer.py::test_no_worked_example_in_the_skill_omits_a_required_element_indexSecure-field floor re-verified after populating
frame/traits/focusedin the payload: a secure record still renders exactly7 textfield <secure>— no title, value, traits, rect or focus marker.Manual verification
CGEventTypes.hread directly from the macOS SDK; both enum values probed live on darwin-arm64 (CGEventSourceGetSourceStateIDreturns an opaque id for-1, literally1for1).is_sensitive_pathplus thecat/>/teeshell forms).autonever resolves toclick_method: "global"— 55 related tests pass.Screenshots
N/A — no user-visible UI change. The only frontend edit is a corrected doc comment in
api/client.ts(theComputerUseConfigDatainterface body was already correct).Gates
pytest21625 passed ·mypy558 files clean (CI-parity venv, no faiss) ·flake8+isort --check-onlyclean ·scrub-lintclean ·tsc -b0 ·vitest483 files / 5893 passed