Skip to content

fix(notify): escape AppleScript specials in pending-question titles - #2322

Merged
qingyun-wu merged 3 commits into
mainfrom
fix/notify-quote-escape
Jul 26, 2026
Merged

qingyun-wu merged 3 commits into
mainfrom
fix/notify-quote-escape

Conversation

@qingyun-wu

Copy link
Copy Markdown
Collaborator

What

notify_macos interpolates question titles straight into an AppleScript string literal. A title containing a double quote makes osascript reject the script — every notification fire reports macos=FAILED for as long as that title sits in the top three. Backslashes and double quotes are now escaped before interpolation.

Before/after evidence (live host, 2026-07-26)

Bug confirmed on upstream/main: two consecutive cron fires logged macos=FAILED while a pending-question title containing a quoted phrase (…health-check says \"all 51 skills…\") was in the top 3; a direct osascript -e 'display notification "plain" …' on the same host succeeded, localizing the failure to title interpolation.

Post-fix, the escaped path with a quote-and-backslash message:

escaped-notify rc: 0

(notification visibly delivered on the host).

ast.parse clean; no other call sites of notify_macos.

🤖 Generated with Claude Code

https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y

A question title containing a double quote broke the osascript string
literal, so every macOS notification fire reported FAILED while that
title sat in the top three (observed 2026-07-26 on a live host: two
consecutive cron fires failed; direct osascript worked, which localized
it to the interpolated titles). Backslashes and double quotes are now
escaped before interpolation.

Evidence: with a title containing quotes, pre-fix osascript exits
non-zero; post-fix the same message notifies with rc=0 (verified live
on the affected host).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DjCFofrj7FTrFnhjMidJ2Y
@github-actions

github-actions Bot commented Jul 26, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 65%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • src/check-pending-questions.py (100%)

Summary

  • Total: 1 line
  • Missing: 0 lines
  • Coverage: 100%

@qingyun-wu qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Reviewed the current head c61efcf. No blocking findings.

The change is narrowly scoped to the AppleScript notification string and correctly escapes backslashes before double quotes before passing the script as a direct osascript -e argument. That preserves the existing subprocess boundary and fixes the quoted-title failure without changing pending-question parsing or delivery behavior.

Focused checks run in an isolated /private/tmp checkout:

  • PYTHONPYCACHEPREFIX=/private/tmp/sutando-pr-2322.pycache python3 -m py_compile src/check-pending-questions.py
  • python3 tests/pending-questions-honest-notify.test.py
  • python3 tests/check-pending-questions-bullet.test.py
  • python3 tests/check-pending-questions-collapse.test.py
  • python3 tests/check-pending-questions-snippet.test.py
  • direct mocked notify_macos check for quote and backslash escaping

Approval signal: looks good to merge from this review. Posted as COMMENT because the authenticated account is the PR author.

Reviewed by Qingyun's Personal Codex.

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reviewed current head c61efcf. The change is narrowly scoped to escaping AppleScript notification titles, the production diff has no hardcoded host-path additions, the PR includes real-host delivery evidence, all required checks are green, and I independently ran python3 -m py_compile src/check-pending-questions.py successfully.

@sonichi sonichi left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

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

Approving per Chi's explicit direction (owner override of the bot-approval default). Code review-verified LGTM this session.

@bassilkhilo-ag2 bassilkhilo-ag2 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Approving — and adding an independent real-world reproduction, since the diff alone doesn't show how cleanly this fails.

Confirmed on a separate host today, including the exact trigger and the fix-by-removal.

check-pending-questions.py reported macos=FAILED while the top-3 pending-question titles included one containing a quoted phrase:

## [CLOSED 15:15Z] "Proactive delivery is broken" - it is not

That heading was one of only 3–4 in the file, so it was certainly inside titles[:3]. After I removed that section for unrelated reasons, the next fire reported macos=ok, and every fire since has been ok. The current file has no double quotes in any heading.

The detail worth having: a manual probe masks this bug. When I saw macos=FAILED I tested with osascript -e 'display notification "test" with title "Sutando"', which succeeded — so I concluded the failure was transient and moved on. It wasn't; it was deterministic and content-dependent. A clean probe string exercises none of the escaping. Anyone debugging this without your fix will reach the same wrong conclusion, which is a good argument for landing it.

The escape order is right — \\ before ", so the quote-escape's own backslash isn't doubled. titles[:3] is the only interpolation into the AppleScript literal, and count is an int, so the escaping is complete for this call site.

One nit, non-blocking: notify_macos's docstring says it "Returns True only if osascript actually accepted the notification", which is what made the FAILED signal trustworthy and pointed at the script rather than the transport. Worth keeping that contract in mind for the sibling with title "Sutando" — the title is a constant today, so there's no second injection point, but a future caller passing a dynamic title would reintroduce this. A tiny _as_str() helper would make that structurally impossible rather than incidentally safe.

@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@qingyun-wu
qingyun-wu merged commit d734ca8 into main Jul 26, 2026
15 of 16 checks passed
@qingyun-wu
qingyun-wu deleted the fix/notify-quote-escape branch July 26, 2026 20:20
yixuan-ag2 added a commit that referenced this pull request Aug 20, 2026
…3117)

* fix(pending-questions): the macOS body sends slugs, not whole titles

notify_macos capped the NUMBER of titles (3) but not their LENGTH, so the
body scaled with title text. Measured on a live host with 26 questions:
388 chars, and macOS truncates aggressively — the reader saw roughly one
partial sentence instead of three question names.

Send the slug (text before the first comma) plus an explicit "(+N more)",
matching health-check's failure notification, which already sends names
rather than prose for the same reason.

  before  len=388  26 pending questions: stale-block-from-down-reviewer,
                   2026-08-18 - #3088 and #3089 are held by a reviewer...
  after   len=121  26 pending questions: stale-block-from-down-reviewer,
                   engine-bundle-missing-redaction, cron-runner-codesigning (+23 more)

#2322 fixed WHICH characters a title may contain; this fixes HOW MUCH of
one may ride in the body. Regression pin fails at the parent (403 > 160).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hg4BFoyjHSyYvgHqdGxPfe

* fix(pending-questions): the cap is the bound, not the comma

Review blocker (liususan091219): the body was bounded by splitting on a
comma, and a title need not contain one. `t.split(",", 1)[0]` returns the
whole title when there is no comma, so the bound disappeared — measured on
their host at 230 chars across 17 live titles, 11 of them comma-less.

Their titles are `[<ts> ET] <ask>`; mine are `slug, date — <ask>`. The fix
worked here because of what this host happens to produce, which is not a
property the code may rely on. Cap the name at 40 chars so the guarantee
holds regardless of punctuation.

The pin passed only because every fixture title carried a comma — the
assertion was real, the input never reached the failing branch. It now
leads with a real comma-less title from the reviewer's host.

Control at the uncapped expression: 251 not less than 160.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Hg4BFoyjHSyYvgHqdGxPfe

* test(pending-questions): pin the cap against three measured title shapes

A second host reports 61 live questions, 44 of them with no comma, in
three vocabularies this notifier had never been tested against:
`[<ts> UTC] …`, `<date> <time> — …`, `[<ts>] …`. None matches the
`slug, date` convention the split keys on, so on that host the split
degenerates to the whole title and only the cap bounds the body.

Control at the uncapped expression, test kept: 199 / 206 / 205 chars
against the 160 assertion, one failure per shape. With the cap: 22/22.

Shapes contributed by @liususan091219 from a live corpus.

* fix(pending-questions): bound the assembled body, not just each name

Per-name [:40] leaves the total arithmetic: both the count prefix and the
`(+N more)` suffix widen with the queue, so the margin depends on how many
questions happen to be pending. Cap the assembled body once instead, so no
count width can overrun it.

Control, per-name cap only vs this head, same tests:

  count      per-name only    with final cap
     26          157 chars        157   ok
    126          159 chars        159   ok
   1226        * 161 chars        159   ok
  12226        * 163 chars        159   ok

It breaks at four digits, which is exactly the ~3-char margin @sonichi
predicted from the two-digit assumption.

Also fixes what the assertions measure. _Capture records argv[-1] — the whole
`display notification "..." with title "Sutando"` — so asserting on it counted
a constant ~44 chars of AppleScript as if it were the body macOS truncates.
The older assertions therefore held a stricter bound than they claimed (~116,
not 160); the new ones extract the notification body and compare against
BODY_MAX directly.

Adds the worst-case shape the fixtures never produced: three maximal 40-char
names.

* test(pending-questions): cover the no-room branch the diff gate flagged

`diff coverage >= 95%` failed on 56d2f2a with src/check-pending-questions.py
at 90.9%, missing line 273 — the `room <= 0` guard I added defensively and
never exercised. The gate was right.

Keeping the branch rather than deleting it: without it, `joined[:room - 1]`
at room == 0 is `joined[:-1]`, which silently drops a character instead of
dropping the names. Reaching it naturally needs a ~63-digit count, so the
test lowers BODY_MAX instead of faking an absurd queue.

* fix(pending-questions): a blank title contributes no name, not a bare comma

qingyun-wu's review nit on #3117: with an empty or whitespace-only title the
join emitted separators with nothing between them —

  before: 5 pending questions: , , real-slug (+2 more)
  after:  5 pending questions: real-slug (+4 more)

Filtering falsy names after the cap also moves the dropped entries into the
remainder rather than losing them, so the count stays honest (+2 -> +4).

The bound is unaffected: `room` subtracts len(extra) before the names are
trimmed, so a wider remainder cannot push the assembled body over BODY_MAX.

test_f6 fails at the parent commit with the exact ragged body above and
passes here; full file 26/26.

* fix(pending-questions): an empty join must not leave a double space

When every candidate title is blank the join is empty and `head` already ends
in a space, so the body rendered `N pending questions:  (+N more)`. Join on the
stripped head in that branch.

Cosmetic and bounded — rstrip only shortens, so the BODY_MAX invariant is
unaffected. test_f7 fails at the parent with
`'  ' unexpectedly found in '9 pending questions:  (+9 more)'` and passes here;
suite 27/27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TNTJu2gT77tcdRJ3TYn83s

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.

4 participants