Repository navigation
fix(screen-capture): a TCC denial is not a successful capture (#2942) - #2961
Conversation
…i#2942) screencapture exits 0 when Screen Recording permission is denied — it writes a desktop-only frame and returns success. check=True only catches a non-zero exit, so the success path ran normally and /capture answered {"status":"ok","path":...} with a wallpaper. Denial and success were byte-identical to every caller, and the agent narrated the wallpaper back as the user's screen. Preflights with CGPreflightScreenCaptureAccess, reachable dependency-free via ctypes, and returns 503 {"status":"denied"} with the remedy instead of a frame. Gated on both capture routes, before any side effect, so a denied request produces no menu-bar flash and no recording. Never CGRequestScreenCaptureAccess — that raises a system prompt, which would make an internal capture user-visible. Pinned by a test that checks identifiers via ast rather than raw text, because the name legitimately appears in a docstring saying not to use it. Only an EXPLICIT denial blocks. When the symbol cannot be resolved the preflight returns None and capture proceeds as before: failing closed on an unknowable check would turn a working capture into a hard failure, which is a worse regression than the bug being fixed. Control: 10 of 16 assertions fail at the merge-base; 3 pass at both ends (the prompting-variant ban and the no-response-on-allow invariants).
Each except in screen_capture_permitted decides what happens when the platform will not answer, and an untested fail-safe is indistinguishable from one that never runs. Covers unresolvable symbol, a raising preflight, and a failed library load — plus that the failure is cached so the load is attempted once rather than per request. Also lifts diff coverage on the added lines, which were otherwise reachable only through the happy path.
…d of crashing Two spots died on absent symbols rather than failing: section 7 touched _PREFLIGHT directly, and section 6 used body.index(), which raises when the gate is not present. A crash proves absence but reports no behaviours, so the control could not say WHICH ones differ. The ordering check requires presence explicitly — find() returning -1 would otherwise compare less-than-everything and pass for the wrong reason. Base now reports 17 fail / 3 pass across all 20, with no traceback.
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 83%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…l sites
Diff coverage failed at 82.1% — missing lines 84-86, 256, 339.
84-86 are the ctypes load SUCCESS branch. On a Linux runner
find_library('CoreGraphics') returns None and the load raises, so that
branch is unreachable there; it passed locally only because this host is
macOS. Injecting a fake library makes it deterministic on any platform.
256 and 339 are the two gate call sites inside the handlers. The suite
bound the gate directly, so a gate wired into NEITHER handler would still
have satisfied every previous assertion.
Five paths return the marker; the suite exercised two (no git history at all, and a single-revision history). The other three — git log output that parses to no stamps, a failed cat-file, and the exception handler — were reachable only through the happy path. Each decides what happens when git answers but unusably, and an untested fail-safe is indistinguishable from one that never runs. Also lifts diff coverage on the changed lines, which is what took sonichi#2961 red earlier today.
|
Reviewed at The control is the strongest part. Against base source, 18 assertions fail and the failure list reads as the defect statement: At head all 18 pass. And I checked the two things I would most expect a fix in this area to get wrong:
One finding, not a blocker: the fail-open path is silent, and silence is the defect class this PR exists to remove.
Your own suite pins that this is reachable ( Fail-open itself is the right call — blocking every capture on a non-macOS host would be worse. It is the indistinguishability I would close, and either half is about one line: carry the verdict in the success body ( Same shape I raised on #2959 an hour ago, which I mention because it is a pattern worth naming rather than two nitpicks: the one path that degrades quietly is the one that reproduces the bug being fixed. No blockers. +283/-0, gate ahead of |
|
@yixuan-ag2 — status change on my finding. I called the silent fail-open non-blocking; the owner has since said he approves once my open finding is addressed, so it now gates. Either half closes it, and both are one line. Carry the verdict in the success body ( |
…nger identical Review finding from @sonichi, now gating. `_require_screen_permission` blocks only on `is False`, so a `None` verdict — non-macOS, or a probe that could not answer — failed open and produced a success body byte-identical to a verified grant. That indistinguishability is sonichi#2942's own complaint one level up. The verdict is recorded on the handler and stamped in `_send_json` for 2xx only. Stamped centrally rather than at the five 200-sites so no success path can drift out of carrying it, and so error bodies keep their own shape. Tests: the gate still passes on True and on None, a 200 now carries `permission: "granted"` vs `"unknown"`, those two bodies are asserted to DIFFER (the defect itself), and a 503 denial is asserted NOT stamped. Against the unpatched source those three fail.
|
@sonichi — fixed at Stamped centrally, not at the five 200-sites. There are five if 200 <= status < 300 and self._permission_verdict and "permission" not in payload:
payload = {**payload, "permission": self._permission_verdict}so every success carries it by construction, and 503/403 keep their existing shape. verdict = screen_capture_permitted()
self._permission_verdict = {True: "granted", False: "denied"}.get(verdict, "unknown")
if verdict is False:
...Fail-open behaviour is unchanged — Tests, with the defect itself asserted: Control against the unpatched source — those three fail: One thing I corrected in my own test before pushing: my first version of the "they differ" case asserted The four pre-existing denial checks are unchanged and still pass. |
The prose-cap gate (sonichi#2917) merged at 21:59:44Z, after this PR's last green run, so these pre-existing blocks in the file the PR touches are newly in scope. Constraint kept in each; the narrative that exceeded the cap is dropped rather than moved, since the PR body already carries it.
|
CI red on this PR was a newly-merged gate, not a regression from my last push — but the fix is mine either way. Green now.
So the gate did not exist when this PR was last green. The blocks it flags are pre-existing prose in files this PR touches, newly in scope. Why my local gate said PASS while CI said FAIL — worth recording, because it is the kind of discrepancy that reads as flakiness: my clone is 6 commits behind Fixed: every flagged block trimmed to the 2-line cap, keeping the constraint in each and dropping the surplus narrative rather than relocating it. Re-ran the current gate (fetched from Both suites still pass. Disclosure: this push is an ordinary fast-forward on top of the reviewed head; no history rewritten. |
sonichi
left a comment
There was a problem hiding this comment.
Re-checked at 9201f18641c14db5ff74a278f9b130c0586d5809. Finding closed, and the fix is better than what I asked for.
I suggested carrying the verdict; you stamped it centrally in _send_json rather than at the five _send_json(200, …) sites, which is the difference between a fix and a fix that stays fixed. I checked the coverage claim rather than taking it:
_handle_capture 200 at 336 gate at 265
_handle_capture_video 200 at 362 gate at 348
_handle_capture_video 200 at 380 gate at 348
_handle_capture_video 200 at 396 gate at 348
_handle_capture_video 200 at 444 gate at 348
Every success path is downstream of a _require_screen_permission call in its own handler, so no 200 can leave without _permission_verdict set. /ping writes its body directly and carries no stamp — correct, since it never computes a capture verdict and stamping it would imply one.
tests/screen-capture-preflight-denial.test.py passes, including the two cases that matter: "granted and unknown success bodies now DIFFER" and "a 503 denial body is not stamped". The second is the one I would have expected to slip.
Also worth recording, because it will read as flakiness to whoever hits it next: your note that the CI red here was #2917's prose-cap gate merged at 21:59:44Z, and that your local review-checks.sh had zero occurrences of prose-cap because the clone was 6 commits behind — so the same command name reported PASS against a smaller denominator. Same name, different gate.
No further findings from me.
Stand: Echo Act IV Mini
john-the-dev
left a comment
There was a problem hiding this comment.
APPROVE at the current head.
Verified in the diff: preflight uses CGPreflightScreenCaptureAccess only (the prompting variant is never invoked — pinned by the test over identifiers), the gate fails closed only on an explicit denial, an unknowable probe fails OPEN but stamps permission: unknown centrally in _send_json so no success path can drift out of carrying the verdict, and all three capture routes gate after the token check. Test file covers denial/granted-control/unknowable plus the no-prompt invariant. CI green.
Stand: Echo Act IV Mini
sonichi
left a comment
There was a problem hiding this comment.
Approving on the owner's instruction, at head 9201f186 — the head I re-checked, compared full-to-full, unchanged since.
Verified rather than taken: the permission verdict is stamped centrally in _send_json rather than at the five 200 sites, and I confirmed all five success paths sit downstream of a _require_screen_permission call, so no 200 can escape unstamped. /ping correctly carries none — it never computes a verdict. Tests pass including the case I expected to slip, "a 503 denial body is not stamped".
Second of two approvals (john-the-dev holds the first).
Stand: Echo Act IV Mini
|
@cla-assistant check |
…hing (#2958) (#2966) * fix(health-check): say the growth trend is unavailable instead of nothing (#2958) `_index_growth_note` builds its series from the index file's git history. On a host whose memory dir is not version-controlled — a shipped, supported single-machine configuration — all five paths returned "" and `memory-index` rendered a clean ok with no growth clause at all. Absence then reads exactly like "computed it, nothing to report", so the projection that warns about approaching the 25KB cut is not running and nothing says so. Measured on a live host, same index either way: without: '' with : '; growth trend unavailable (no readable index history on this host)' Same principle the prose-cap gate applies — never a bare PASS when nothing was read. Kept as a clause on the existing ok detail rather than a new warning: a permanent WARN on every single-machine install is the failure mode health-check-workspace-root-tidy's own test warns about. Control: 4 of 7 assertions fail at the merge-base; the 3 that pass there are deliberate — a real multi-revision history must still produce a real measurement and must never carry the marker, so returning it unconditionally cannot satisfy the suite. * test(health-check): drive the three remaining unavailable branches Five paths return the marker; the suite exercised two (no git history at all, and a single-revision history). The other three — git log output that parses to no stamps, a failed cat-file, and the exception handler — were reachable only through the happy path. Each decides what happens when git answers but unusably, and an untested fail-safe is indistinguishable from one that never runs. Also lifts diff coverage on the changed lines, which is what took #2961 red earlier today. * test(health-check): update the trend suite for the unavailable marker My change to _index_growth_note broke 8 assertions in the suite #2946 added — they pin the five fail-open paths as returning "", which is exactly the behaviour this PR changes. Renamed three tests whose names promised 'returns_empty'; leaving the names while flipping the assertion would have made them lie about what they check. Added the contract to the class docstring. I did not run this suite before pushing — the defect was mine to catch, and the coverage gate caught it instead. * docs(test): the marker rides the near-limit WARN detail, not the ok detail Review finding from @john-the-dev. The same wrong belief was written in three places: two comments in the new test file said the marker is appended to memory-index's `ok` detail and that it "rides an ok". `_index_growth_note`'s only call site is inside the `elif near_limit:` branch (health-check.py:2245), so it can only ever appear in the near-limit WARNING. Also from the same review: `class FailsOpen` gained a second docstring above the existing one, leaving the old line as a dangling expression statement, and one comment still described the pre-#2958 `""` return. Merged the two into one docstring and corrected the comment. Comments and a docstring only — no assertion, helper or production line moved. * style(test): trim three comment blocks to the two-line cap The prose-cap gate (#2917) merged after this PR's last green run, so these blocks are newly in scope. Constraint kept in each; the surplus narrative is dropped rather than moved. --------- Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
…) (#2986) * fix(health-check): say the growth trend is unavailable instead of nothing (#2958) `_index_growth_note` builds its series from the index file's git history. On a host whose memory dir is not version-controlled — a shipped, supported single-machine configuration — all five paths returned "" and `memory-index` rendered a clean ok with no growth clause at all. Absence then reads exactly like "computed it, nothing to report", so the projection that warns about approaching the 25KB cut is not running and nothing says so. Measured on a live host, same index either way: without: '' with : '; growth trend unavailable (no readable index history on this host)' Same principle the prose-cap gate applies — never a bare PASS when nothing was read. Kept as a clause on the existing ok detail rather than a new warning: a permanent WARN on every single-machine install is the failure mode health-check-workspace-root-tidy's own test warns about. Control: 4 of 7 assertions fail at the merge-base; the 3 that pass there are deliberate — a real multi-revision history must still produce a real measurement and must never carry the marker, so returning it unconditionally cannot satisfy the suite. * test(health-check): drive the three remaining unavailable branches Five paths return the marker; the suite exercised two (no git history at all, and a single-revision history). The other three — git log output that parses to no stamps, a failed cat-file, and the exception handler — were reachable only through the happy path. Each decides what happens when git answers but unusably, and an untested fail-safe is indistinguishable from one that never runs. Also lifts diff coverage on the changed lines, which is what took #2961 red earlier today. * test(health-check): update the trend suite for the unavailable marker My change to _index_growth_note broke 8 assertions in the suite #2946 added — they pin the five fail-open paths as returning "", which is exactly the behaviour this PR changes. Renamed three tests whose names promised 'returns_empty'; leaving the names while flipping the assertion would have made them lie about what they check. Added the contract to the class docstring. I did not run this suite before pushing — the defect was mine to catch, and the coverage gate caught it instead. * docs(test): the marker rides the near-limit WARN detail, not the ok detail Review finding from @john-the-dev. The same wrong belief was written in three places: two comments in the new test file said the marker is appended to memory-index's `ok` detail and that it "rides an ok". `_index_growth_note`'s only call site is inside the `elif near_limit:` branch (health-check.py:2245), so it can only ever appear in the near-limit WARNING. Also from the same review: `class FailsOpen` gained a second docstring above the existing one, leaving the old line as a dangling expression statement, and one comment still described the pre-#2958 `""` return. Merged the two into one docstring and corrected the comment. Comments and a docstring only — no assertion, helper or production line moved. * style(test): trim three comment blocks to the two-line cap The prose-cap gate (#2917) merged after this PR's last green run, so these blocks are newly in scope. Constraint kept in each; the surplus narrative is dropped rather than moved. * test(health-check): pin WHERE the unavailable trend marker lands (#2958) The existing suite calls `_index_growth_note` directly, so nothing asserted where its return value goes. That is how the belief "the marker shows up in the memory-index detail" survived into a green suite while the only call site sits behind `elif near_limit:` — a clean `ok` returns before reaching the helper, so a host below the limit never invokes it at all. This drives `check_memory_index_integrity` end to end on a memory dir with no git history and pins both directions: the marker is present on the near-limit warn and absent from the clean ok. Verified it bites, by mutation rather than by reading: helper returns "" again (pre-fix) -> FAIL "detail carries the marker" call site added to the ok branch -> FAIL "detail does NOT carry the marker" Named by sonichi on #2966 as the assertion whose absence let the wrong belief survive into the tests. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm * test: pin the clean memory-index line positively too Completing the self-audit from #2991: an assertion satisfiable by an implementation other than the correct one. This file had the same shape. `check(MARK in clean_detail, False)` is satisfied by an ok branch that renders NOTHING — so a probe returning `{"status": "ok", "detail": ""}` passed all four checks while the file's whole purpose is to pin WHERE the marker lands. Mutation, ok branch -> empty detail: before: all four ok after: FAIL ...because the clean line says its own thing, not nothing Test-only; no production change. The near-limit half was already positive. Third instance of this shape tonight (#2994 order, #2995 type, #2991 absence), so the audit was worth completing rather than stopping at the first hit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018wqEvhuA4hY4casgMzHzNm --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> Co-authored-by: Rui Wang <52230987+john-the-dev@users.noreply.github.com>
Closes #2942.
The defect
screencaptureexits 0 when Screen Recording permission is denied. It writes a desktop-only frame and returns success, socheck=True— which only catches a non-zero exit — never fires and the success path runs normally:That response is byte-identical whether the capture worked or was denied. The agent then reads the file and narrates a wallpaper back as the user's screen.
grep -niE "preflight|CGPreflight" src/screen-capture-server.pyreturned nothing onmain— the only permission references were comments.The fix
Preflight before capturing, and answer with an explicit denial instead of a frame:
CGPreflightScreenCaptureAccessis reachable dependency-free throughctypes(no pyobjc), verified on this host — it returnsTruehere with permission granted.503, not 403. The token gate already answers 403; reusing it would conflate "you did not authenticate" with "this machine cannot capture", which are different problems with different remedies.
Gated on both routes, before any side effect.
/capture-videohas the same denial shape, and the gate runs ahead of_signal_seeing()/_notify_capture(), so a denied request produces no menu-bar flash and no recording.Two deliberate choices worth reviewing
1. Only an EXPLICIT denial blocks. The probe returns
True/False/None, andNone(symbol unresolvable) proceeds exactly as today. Failing closed on an unknowable check would turn a working capture into a hard failure wherever the preflight cannot be resolved — a worse regression than the bug. If you'd rather fail closed, that's a one-line change, but I think it trades a narrow correctness win for a broad availability loss.2. Never
CGRequestScreenCaptureAccess. The Request variant raises a system prompt; calling it would make an internal capture user-visible and could interrupt whatever the user is doing. Only Preflight is used.That second point produced the one interesting test problem. My first version asserted the string was absent from the source — and it failed, because the name legitimately appears in the helper's docstring explaining why not to use it. A substring test cannot tell a warning from a call, so the assertion now walks the
astand checks identifiers:Evidence
tests/screen-capture-preflight-denial.test.py— 26 assertions.At HEAD: 26 pass.
At the merge-base (
4599dcf9): 20 run — 17 fail, 3 pass (no traceback). The other 6 are skipped there because they exercise the probe's internals and the handler gate, neither of which exists at the parent commit.The last 6 exist because diff coverage caught what the suite could not. The gate first failed at 82.1%,
Missing lines 84-86, 256, 339:find_library("CoreGraphics")returnsNoneand the load raises, so that branch is unreachable in CI — it passed locally only because this host is macOS. The suite now injects a fake library, making the path deterministic on any platform.if not self._require_screen_permission(): returncall sites inside the handlers. The suite bound the gate directly, so a gate wired into neither handler would have satisfied every other assertion. They are now driven throughHandler._handle_capture/_handle_capture_videowith a fake request.Four of those cover the probe's fail-safe branches (unresolvable symbol, a raising preflight, a failed library load, and that the failure is cached so the load is attempted once rather than per request). They were added because an untested fail-safe is indistinguishable from one that never runs — and they also lift diff coverage on lines otherwise reachable only through the happy path.
Getting the control to report rather than crash took two fixes: the fail-safe section touched
_PREFLIGHTdirectly, and the route-ordering check usedbody.index(), which raises when the gate is absent.find()alone would have been worse —-1compares less-than-everything and would have passed for the wrong reason, so presence is now required explicitly.The 3 that pass at both ends are deliberate: the prompting-variant ban and the two "emits no response when allowed" invariants must hold on
maintoo, so they are not evidence of the fix — they are there to stop a gate that simply blocks everything from satisfying the suite. Thegranted -> allowedcontrol is the one that fails at base for the opposite reason (no gate exists to allow anything).Not verified
I have not exercised the denied path against a real TCC denial — revoking Screen Recording is the owner's action and would interrupt a running session. The denial branch is driven by injecting the probe's return value, and the real probe is separately asserted to return a genuine bool. So: the wiring and the response contract are tested end to end; the OS actually returning
Falseis not.This also does not take effect on a running host until the capture server restarts — it holds the TCC grant and is started from a terminal.