Skip to content

FIRE-1947 | Cowork block modal (stacked on #40) - #41

Merged
yuval-qf merged 3 commits into
hotfix/cowork-actor-fixfrom
hotfix/cowork-osascript
Aug 19, 2026
Merged

FIRE-1947 | Cowork block modal (stacked on #40)#41
yuval-qf merged 3 commits into
hotfix/cowork-actor-fixfrom
hotfix/cowork-osascript

Conversation

@yuval-qf

@yuval-qf yuval-qf commented Aug 18, 2026

Copy link
Copy Markdown
Contributor

Stacked PR. Base is hotfix/cowork-actor-fix (#40), not main.
main#40 hotfix/cowork-actor-fixthis PR hotfix/cowork-osascript
Merge #40 first; this PR's base then retargets to main automatically and its
diff shrinks to just the commit below.

Re-adds the native block modal that c31ee5a deleted — for Claude Cowork local sessions only. The CLI and the Claude Desktop app keep getting no modal.

Why

c31ee5a removed security-alert.{sh,ps1} on the grounds that "Claude Desktop now displays hook blocks natively, so the plugin is pure relay". That was correct for the surfaces that existed then, and is still correct for both of them. Claude Cowork was not a surface when that commit landed, and it discards hook-authored text on every documented channel.

Measured 2026-08-18, one session per surface, with a probe plugin that recorded its own uname/whoami/HOME/process ancestry/env from inside the hook process:

block channel Cowork cloud Cowork local
{"decision":"block","reason":R} spinner hangs forever, no text turn stops, no text
{"continue":false,"stopReason":R} spinner hangs forever, no text turn stops, no text
systemMessage alongside a block turn stops, no text
hook exit code 2 + message on stderr turn stops, no text
hookSpecificOutput.additionalContext renders (model relays it) renders (model relays it)
native osascript modal impossible — no GUI works

Four independent documented channels reach the same dead end, so no JSON shape fixes this. additionalContext is not a substitute — it reaches the model, which may or may not repeat it. In a local session the OS modal is the only channel that reaches the user.

The gate

_rogue_want_alert (hook.sh) / Test-WantAlert (hook.ps1), four tests, cheap ones first:

  1. Cowork only — reuses ROGUE_INSTALL_AGENT == "claude_cowork" from install-id.sh rather than re-deriving the surface. Local Cowork's entrypoint is local-agent, not a *cowork* value, so entrypoint matching alone files it as the CLI; install-id.sh already handles that, and a second copy would drift from the roster's.
  2. Local onlyCLAUDE_CODE_REMOTE set means the cloud container (Linux, root, HOME=/root, no osascript, no DISPLAY, no DBUS). Excluded explicitly rather than left to fail silently, so ~/.rogue/hook.log stays honest.
  3. Escape hatchesROGUE_ALERT=0 disables; ROGUE_ALERT_EVENTS is a space-separated event allowlist.
  4. Capability lastcommand -v osascript. The env vars above are undocumented and will move.

A declined alert logs alert_skipped=1 entrypoint=… cowork=… remote=… agent=…; a fired one logs alert_rc=<exit> (TCC denials and osascript failures are otherwise invisible).

The relay is untouched. The modal is a side-channel — the server body still goes back verbatim, and every path still exits 0.

Two deviations from the plan, both deliberate

  • security-alert.sh is NOT restored byte-for-byte. The historical version wraps its osascript in tell application "System Events". A cross-app tell is an Automation request, so macOS attributes it to the host app and prompts "Claude" wants access to control "System Events" on the first block — a consent dialog in front of a security alert, which a user can deny, permanently killing it. plugins/copilot already learned this under PyCharm and CLAUDE.md documents it. Replaced with the bare display alert + top-level activate form (needs no permission). Everything else is byte-for-byte, including the bash-only \n conversion — hence bash, not sh, at the call site.
  • The gate reuses install-id.sh's surface resolution instead of re-deriving the Cowork cascade inline, per CLAUDE.md's "resolve host/version/agent in ONE place per plugin".

No version bump in this PR. #40 already carries 1.0.231.0.24, and the two merge together, so a second bump would ship two releases for one merge. (The plan asked for 1.0.25 plus a bump to install.{sh,ps1}; those installer files carry no version literal anyway — 1.0.24's installer edits were the agent-id fix.)

Tests

  • tests/test_hook_sh.sh — 10 new end-to-end cases: Cowork local fires, cloud skips, CLI/Desktop skip, no-osascript skips, an allow response fires nothing, a failing osascript is still fail-open, ROGUE_ALERT_EVENTS narrows, ROGUE_ALERT=0 kills. Every case also asserts the body is relayed verbatim.
    • A hanging osascript stub regression-tests the fd detachment from dbfd3ee. Verified to fail (30 s wait) when the >/dev/null 2>&1 </dev/null is removed — without it the subshell holds Claude's read pipe open until the dialog is dismissed, Claude times the hook out, and the block fails open.
    • Cases run against a sandboxed PATH holding no osascript, so a dev Mac's real /usr/bin/osascript can neither defeat the capability case nor pop a dialog on the tester's screen. (It did both on the first draft.)
  • tests/test_hook_ps1.ps1 — 13 new cases: the full gate matrix, plus static assertions that the response is relayed before the launch and that the launch stays detached / hidden / -EncodedCommand.
  • tests/test_hooks_json.sh passes unchanged — no hooks.json change; the 27-command polyglot lint is undisturbed.

Local run: 40 hook.sh assertions pass under both sh and dash; 70 hook.ps1 assertions pass; all shell scripts parse; all .ps1 parse; manifest/version-sync gates pass. (tests/test_hook_ps1_antigravity.ps1 has 2 failures — a Resolve-Url $script: scope lint in the antigravity plugin — which pre-exist on the base branch and are not in CI's list.)

Open question, deliberately left open

PreToolUse permissionDecision:deny does render in Cowork cloud (observed twice — CREDENTIAL_THEFT, DANGEROUS_OPERATION, reason visible, no hang). It has not been tested in Cowork local. If tool denials render locally too, the modal should narrow to UserPromptSubmit — the one event with no visible channel — or tool blocks get reported twice. Until that is measured, firing on every block event matches pre-c31ee5a behaviour, and ROGUE_ALERT_EVENTS="UserPromptSubmit" narrows it without a release.

Verification still owed (needs a real Cowork session)

  1. Local Cowork, prompt with two email addresses → turn stops, modal reads ⛔ Rogue blocked this prompt, log shows outcome=block then alert_rc=0 entrypoint=local-agent.
  2. Cloud Cowork, same prompt → no modal, alert_skipped=1 … remote=true, no osascript: command not found.
  3. CLI, same prompt → block rendered natively, alert_skipped=1 … cowork=unset.

Delete this whole path once Cowork renders the reason itself, exactly as it was deleted once Claude Desktop did.

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 61bfb5d6-813c-41ae-aba2-f92fbd3ed166

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

Comment @coderabbitai help to get the list of available commands.

@yuval-qf yuval-qf mentioned this pull request Aug 18, 2026
c31ee5a deleted security-alert.{sh,ps1} and the modal-firing branches because
"Claude Desktop now displays hook blocks natively, so the plugin is pure relay".
That was correct for the surfaces that existed then, and is still correct for the
CLI and the Desktop app. Claude Cowork, which was not a surface when that commit
landed, breaks the claim: its client discards hook-authored text on EVERY
documented channel.

Measured 2026-08-18, one session per surface, with a probe plugin recording its
own uname/whoami/HOME/process-ancestry/env from inside the hook process:

  channel                              cowork cloud            cowork local
  {"decision":"block","reason":R}      spinner hangs, no text  stops, NO text
  {"continue":false,"stopReason":R}    spinner hangs, no text  stops, NO text
  systemMessage alongside a block      -                       stops, NO text
  exit code 2 + message on stderr      -                       stops, NO text
  hookSpecificOutput.additionalContext renders (model relays)  renders
  native osascript modal               impossible (no GUI)     WORKS

Four independent documented channels reach the same dead end, so no JSON shape
fixes this. In a local session the OS modal is the only channel that reaches the
user, and additionalContext is not a substitute (it reaches the model, which may
or may not repeat it).

So the modal comes back, gated to that one surface:

  * _rogue_want_alert (hook.sh) / Test-WantAlert (hook.ps1), four tests, cheap
    first: surface == claude_cowork, then not CLAUDE_CODE_REMOTE (cloud runs the
    hook in a headless Linux container - root, HOME=/root, no osascript, no
    DISPLAY, no DBUS), then the ROGUE_ALERT / ROGUE_ALERT_EVENTS escape hatches,
    then `command -v osascript` as the final authority.
  * The surface comes from install-id.sh's ROGUE_INSTALL_AGENT rather than a
    second copy of the CLAUDE_CODE_IS_COWORK-first cascade. Local Cowork's
    entrypoint is `local-agent`, NOT a *cowork* value, so entrypoint matching
    alone files it as the CLI - install-id.sh already knows that, and a duplicate
    would drift from the roster's.
  * Cloud is excluded EXPLICITLY, not left to fail silently: a declined alert
    logs alert_skipped=1 with all four gate inputs, so hook.log stays honest
    about what the user was and was not told.
  * The relay is untouched. The modal is a side-channel; the server body still
    goes back verbatim and every path still exits 0.

security-alert.{sh,ps1} are restored from c31ee5a^ with one deliberate change:
the osascript is no longer wrapped in `tell application "System Events"`. A
cross-app tell is an Automation request, so macOS attributes it to the host app
and prompts '"Claude" wants access to control "System Events"' on the first
block - a consent dialog in front of a security alert, which a user can deny,
permanently killing it. plugins/copilot learned this under PyCharm; a bare
`display alert` needs no permission and a top-level `activate` targets osascript
itself. Everything else is byte-for-byte, including the bash-only \n conversion
(hence `bash`, not `sh`, at the call site).

Two things that must not be simplified are re-landed with their reasoning:
the sh side's `( ... ) >/dev/null 2>&1 </dev/null &`, which detaches the
SUBSHELL's fds and not just the alert's (dbfd3ee: without it the subshell holds
Claude's read pipe open until the dialog is dismissed, Claude times the hook out
and FAILS OPEN, letting the blocked prompt through), and the ps1 side's
relay-then-launch ordering with -EncodedCommand.

Tests:
  * tests/test_hook_sh.sh - 10 new end-to-end cases (cowork local fires, cloud
    skips, CLI/Desktop skip, no-osascript skips, allow fires nothing, a failing
    osascript is still fail-open, ROGUE_ALERT_EVENTS narrows, ROGUE_ALERT=0
    kills). Each also asserts the body is relayed verbatim. A HANGING osascript
    stub regression-tests the fd detachment - verified to fail (30s wait) when
    the redirection is removed. Cases run against a sandboxed PATH holding no
    osascript, so a dev Mac's real /usr/bin/osascript can neither defeat the
    capability case nor pop a dialog on the tester's screen.
  * tests/test_hook_ps1.ps1 - 13 new cases: the gate matrix, plus static
    assertions that the response is relayed BEFORE the launch and that the
    launch stays detached/hidden/-EncodedCommand.
  * tests/test_hooks_json.sh passes unchanged - no hooks.json change.

No version bump here: this PR is stacked on hotfix/cowork-actor-fix, which already
carries 1.0.23 -> 1.0.24, and the two merge together — a second bump would ship
two releases for one merge.

Delete this whole path once Cowork renders the reason itself, exactly as it was
deleted once Claude Desktop did.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yuval-qf
yuval-qf force-pushed the hotfix/cowork-osascript branch from 10f41d4 to e65e43e Compare August 18, 2026 12:58
…stale docs

Both from PR review.

P2 — alert_rc was useless for exactly the failures it exists to surface.
security-alert.sh was restored verbatim from c31ee5a^, which ends both osascript
branches with `|| true` and the script with `exit 0`. So the helper ALWAYS exited
0 whenever osascript was on PATH, and hook.sh's `log "alert_rc=$?"` recorded 0 for
a TCC denial, an AppleScript syntax error, a missing GUI session — every case the
line was added to make visible. stdout and stderr were discarded too, so nothing
else carried the signal either. Reproduced with a stub osascript exiting 1: the
helper still returned 0.

  * security-alert.sh now ends each branch `exit $?` and lets osascript's /
    notify-send's stderr through (only stdout — the dialog result record — is
    discarded). Safe because the caller backgrounds the helper in a subshell that
    only LOGS the status; a non-zero exit there cannot fail the hook decision,
    which is the same property that already lets the modal hang without harm.
  * The no-channel-at-all fallback now exits 127 instead of 0. An alert nobody
    saw must not log as a success. (Unreachable from hook.sh, whose gate already
    requires osascript.)
  * hook.sh captures the helper's stderr (`2>&1 >/dev/null`, in that order) and
    logs `alert_err="…"` alongside a non-zero alert_rc. A bare rc is ambiguous:
    osascript returns 1 for a TCC denial, a syntax error, a missing GUI session
    AND for "User canceled" (-128), so the reason text is what makes the status
    actionable. Folded to one line BEFORE sanitizing (sanitize deletes control
    characters, so newlines would run words together) and capped at 200 bytes.

  The review is also right that the old test was too weak: `assert_log 'alert_rc='`
  passes against the broken helper. tests/test_hook_sh.sh now asserts `alert_rc=1`
  exactly plus the captured stderr, and a new case asserts `alert_rc=0` with NO
  alert_err on a clean run. The failing stub emits two lines on stderr, so the
  fold-and-sanitize path is covered. Verified the new assertions FAIL against the
  pre-fix helper.

P3 — docs/deployment.md was stale in two places, both about this modal.
  * The troubleshooting row told users to fix a missing modal by granting System
    Events Automation permission. This PR removes the `tell application "System
    Events"` path precisely so no Automation grant is needed, making that advice
    misleading for the exact failure the hotfix addresses. Replaced with the real
    gates (Cowork local only; CLAUDE_CODE_REMOTE unset; ROGUE_ALERT /
    ROGUE_ALERT_EVENTS; osascript present) and how to read alert_skipped /
    alert_rc / alert_err out of ~/.rogue/hook.log, stating outright that an
    Automation grant is not the fix.
  * The Windows paragraph claimed the block modal uses
    System.Windows.Forms.MessageBox. security-alert.ps1 deliberately uses
    WScript.Shell.Popup — MessageBox::Show silently no-ops from the detached
    hidden process the hook launches it in, which is why it was chosen. Corrected,
    and noted the modal is Cowork-local-only on every platform.

No version change: still deferring to hotfix/cowork-actor-fix's bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yuval-qf yuval-qf changed the title HOTFIX | Cowork block modal (stacked on #40) FIRE-1947 | Cowork block modal (stacked on #40) Aug 18, 2026
yuval-qf added a commit that referenced this pull request Aug 18, 2026
main landed the log-shipping feature (~10k lines) on top of a released v1.0.26,
which conflicted with this branch in 9 files. Most were disjoint additions; two
were genuine semantic collisions about the SAME question — how the surface is
resolved — where each side was right about a different half:

  * main introduced scripts/surface.{sh,ps1}: ONE table, because two consumers
    (hook's `surface=` log slug and the heartbeat's roster `agent`) must never
    name different surfaces for one session.
  * this branch changed the roster `agent` from a display label to a stable
    snake_case id (it doubles as the backend's PLUGIN_REPOS key, and a label
    matched none, so every Claude row read as up to date), and made Cowork
    detection check CLAUDE_CODE_IS_COWORK BEFORE the entrypoint — Cowork spawns
    Claude Code with CLAUDE_CODE_ENTRYPOINT=local-agent, not a *cowork* value, so
    entrypoint matching alone filed every LOCAL Cowork install under the CLI.

Taking either side alone loses the other's fix, and main's table has BOTH bugs
this branch fixed. Resolved by keeping the single table and giving it a third
projection:

  * surface.{sh,ps1}: added rogue_surface_agent_id / Get-RogueSurfaceAgentId
    (claude_code | claude_code_desktop | claude_cowork), and moved the
    CLAUDE_CODE_IS_COWORK check to the FRONT of rogue_surface_slug so all three
    projections agree about Cowork. The slug/label behaviour for every entrypoint
    is unchanged; what changes is that IS_COWORK now wins, which is the fix.
  * install-id.sh, heartbeat.ps1, hook.ps1 and skills/status/SKILL.md now READ
    that projection instead of inlining a cascade — so there is one table with
    three consumers rather than four copies. Fallback literals are `claude_code`,
    not `Claude Code - CLI`.

Other resolutions:
  * version → 1.0.27. main already RELEASED v1.0.26, so this branch's 1.0.24
    would have been a downgrade: auto-update compares the manifest against the
    latest release tag, so a merged 1.0.24 reads as older than what is published.
    One bump for the whole stack; the stacked child (#41) carries none.
  * validate.yml: both sides added disjoint steps — main runs none of this
    branch's four new sh suites — so both are kept, this branch's "Shell unit
    tests" after "Shell scripts parse".
  * hook.ps1: both sides ended mid-function at the marker (main inside Log, this
    branch inside Select-ActorValue) with ONE closing brace in the common
    context; main's block gets an explicit brace and this branch's keeps the
    shared one. All four functions stay above the ROGUE_PS_LIB_ONLY seam.
  * heartbeat.sh / CLAUDE.md: main's newer prose about the two triggers, with
    this branch's corrected actor-cascade and `agent` field descriptions.
  * SKILL.md: this branch's resolved-actor reporting AND main's hook-activity
    log tail; main's duplicate raw-value echoes dropped, since this branch
    reports the cascade's output instead.

tests/test_status_skill_sh.sh stages surface.sh into its fake plugin root — the
skill now reads the shared table as a real install does, and without it the
fixture fell back to the damaged-install literal and reported claude_code for a
Cowork session. tests/test_hook_logs.{sh,ps1} gain the agent-id projection and the
IS_COWORK-beats-entrypoint rows in all three projections, in both twins.

Verified: 11 sh suites and 6 PowerShell suites pass; all shell + all .ps1 parse;
JSON valid; version sync ok for all four plugins; sync-shared-scripts --check
clean; main's own snippet-parse gate passes over all 13 command/skill docs.
tests/test_hook_sh_copilot.sh fails and test_hook_sh_antigravity.sh flakes on a
cleanup race — both reproduced on origin/main untouched, neither caused here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pulls the parent's main-merge (and main's log-shipping feature) up into the
stacked child, so #41 is mergeable once #40 lands.

hook.sh and hook.ps1 merged cleanly despite main restructuring both — the modal
lives at the end of the block branch, main's changes were in logging and surface
resolution. Verified the pieces the Cowork gate depends on are still ordered
correctly: install-id.sh is sourced (hook.sh:153) before block detection (:187)
and the gate call (:202), so ROGUE_INSTALL_AGENT is populated; on the PowerShell
side $installAgent now comes from Get-RogueSurfaceAgentId, the shared projection
the parent merge introduced, which answers `claude_cowork` — the exact value
Test-WantAlert compares against. Had the parent kept main's display-label version
of that resolution, the modal would have silently never fired in local Cowork.

Only CLAUDE.md conflicted, and only as two disjoint additions: this branch's
"The Cowork block modal" section plus main's "The log shipper" section, and one
bullet each in "Things that look weird". Both kept. Ordering is deliberate — the
Cowork section is a `###` and has to stay ahead of main's `##  The log shipper`
or it would re-nest as a subsection of the log shipper.

No version bump here: the parent carries 1.0.23 -> 1.0.27 for the whole stack
(1.0.27, not 1.0.24, because main released v1.0.26 in the meantime and a merged
1.0.24 would read as older than what is published).

Verified on the merged tree: all 27 Cowork-modal assertions still pass under both
sh and dash, 11 sh suites and 6 PowerShell suites pass (70 hook.ps1 assertions),
all shell and .ps1 parse, sync-shared-scripts --check clean, and #41's own diff
against its parent is still exactly its 8 files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@yuval-qf
yuval-qf merged commit 70be710 into main Aug 19, 2026
5 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants