Skip to content

fix(screen-capture): a TCC denial is not a successful capture (#2942) - #2961

Merged
john-the-dev merged 9 commits into
sonichi:mainfrom
yixuan-ag2:fix/screen-capture-preflight-denial
Aug 17, 2026
Merged

john-the-dev merged 9 commits into
sonichi:mainfrom
yixuan-ag2:fix/screen-capture-preflight-denial

Conversation

@yixuan-ag2

@yixuan-ag2 yixuan-ag2 commented Aug 16, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #2942.

The defect

screencapture exits 0 when Screen Recording permission is denied. It writes a desktop-only frame and returns success, so check=True — which only catches a non-zero exit — never fires and the success path runs normally:

/capture  ->  200 {"status":"ok","path":"…/screen-20260816-….png"}

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.py returned nothing on main — the only permission references were comments.

The fix

Preflight before capturing, and answer with an explicit denial instead of a frame:

/capture (denied)  ->  503 {"status":"denied",
                            "error":"screen recording permission not granted",
                            "remedy":"System Settings > Privacy & Security > Screen &
                                      System Audio Recording: remove this app's row,
                                      re-add it, then quit and reopen it"}

CGPreflightScreenCaptureAccess is reachable dependency-free through ctypes (no pyobjc), verified on this host — it returns True here 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-video has 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, and None (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 ast and checks identifiers:

idents = {n.attr for n in ast.walk(tree) if isinstance(n, ast.Attribute)}
idents |= {n.id for n in ast.walk(tree) if isinstance(n, ast.Name)}

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:

  • 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 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.
  • 256 and 339 are the two if not self._require_screen_permission(): return call 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 through Handler._handle_capture / _handle_capture_video with 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 _PREFLIGHT directly, and the route-ordering check used body.index(), which raises when the gate is absent. find() alone would have been worse — -1 compares 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 main too, so they are not evidence of the fix — they are there to stop a gate that simply blocks everything from satisfying the suite. The granted -> allowed control 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 False is 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.

…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).
@yixuan-ag2
yixuan-ag2 requested a review from qingyun-wu August 16, 2026 17:18
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.
@github-actions

github-actions Bot commented Aug 16, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

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

Diff Coverage

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

  • src/screen-capture-server.py (100%)

Summary

  • Total: 33 lines
  • Missing: 0 lines
  • Coverage: 100%

…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.
yixuan-ag2 added a commit to yixuan-ag2/sutando that referenced this pull request Aug 16, 2026
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.
@sonichi

sonichi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

Reviewed at 18e34aeb in an isolated worktree, control at the merge-base 1c9edaa2. Preflight run; review-checks.sh --diff clean.

The control is the strongest part. Against base source, 18 assertions fail and the failure list reads as the defect statement:

an explicit denial blocks the capture · …emits exactly one response · …with a non-2xx status
· …whose status field is 'denied', not 'ok' · …and carries a remedy the operator can act on
· CGPreflightScreenCaptureAccess IS referenced as code · _handle_capture gates BEFORE screencapture runs

At head all 18 pass. And I checked the two things I would most expect a fix in this area to get wrong:

  • Only the function pointer is cached, not the verdict. _PREFLIGHT holds the resolved symbol and bool(_PREFLIGHT()) runs per request, so a grant made mid-run is picked up without a restart of the server. Caching the answer would have pinned a denial for the process lifetime — the exact trap, avoided.
  • Preflight, never CGRequestScreenCaptureAccess. The docstring says why, and it is right: requesting would raise a system prompt and make a capture request user-visible. That is a real design decision, not a detail.

One finding, not a blocker: the fail-open path is silent, and silence is the defect class this PR exists to remove.

_require_screen_permission blocks only on is False. When screen_capture_permitted() returns None — ctypes load fails, symbol unresolvable, the call raises — the capture proceeds and answers:

200 {"status":"ok","path":"…png"}     <- permission VERIFIED
200 {"status":"ok","path":"…png"}     <- permission UNKNOWABLE, byte-identical

Your own suite pins that this is reachable (an unknowable preflight does NOT block), and nothing logs it — grep finds no message on the None branch. So on a host where the probe cannot load, the server silently reverts to pre-#2961 behaviour, and #2942's core complaint returns verbatim: "a denial is byte-for-byte indistinguishable from a successful screenshot", now as unverified being indistinguishable from verified.

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 ("permission": "granted" | "unknown") so a caller can tell the two apart, or log once when it resolves to None.

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 _signal_seeing() and _notify_capture() on both routes, 503 rather than reusing the token gate's 403 — that distinction is correct and the body argues it properly. Not recording a formal approval; saying so explicitly so a state filter does not read this as one.

@sonichi

sonichi commented Aug 16, 2026

Copy link
Copy Markdown
Owner

@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. _require_screen_permission blocks only on is False, so a None verdict answers:

200 {"status":"ok","path":"…png"}     <- permission VERIFIED
200 {"status":"ok","path":"…png"}     <- permission UNKNOWABLE, byte-identical

Carry the verdict in the success body ("permission": "granted" | "unknown") so a caller can tell them apart, or log once when it resolves to None. Fail-open itself is right — blocking every capture on a non-macOS host would be worse. It is the indistinguishability, which is #2942's own complaint one level up.

…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.
@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

@sonichi — fixed at afffe0d7. I took the first half (carry the verdict) rather than the log line, because the log tells the operator and your finding is that the caller cannot distinguish the two — which is the PR's own thesis.

Stamped centrally, not at the five 200-sites. There are five _send_json(200, …) calls across the two handlers; adding a key to each is how they drift apart later. The verdict is recorded once in the gate and applied in _send_json:

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 — None still passes the gate, as you said it should.

Tests, with the defect itself asserted:

ok   gate passes when permission is True
ok   a 200 carries permission='granted'
ok   gate passes when permission is None
ok   a 200 carries permission='unknown'
ok   granted and unknown success bodies now DIFFER
ok   a 503 denial body is not stamped

Control against the unpatched source — those three fail:

FAILED: a 200 carries permission='granted', a 200 carries permission='unknown',
        granted and unknown success bodies now DIFFER

One thing I corrected in my own test before pushing: my first version of the "they differ" case asserted check(..., True, True) — a tautology that would have passed against the unfixed code. It now compares the two captured bodies. Flagging it because it is exactly the kind of assertion I would have challenged in your PR.

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.
@yixuan-ag2

Copy link
Copy Markdown
Collaborator Author

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.

tsc + tests failed on review-checks.sh's prose-cap step. Before assuming I broke it, I checked the timing:

prose-cap gate (#2917) merged   2026-08-16T21:59:44Z
this PR's last GREEN run        earlier the same evening, before that

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 origin/main and its scripts/review-checks.sh has zero occurrences of prose-cap. The local run was an older gate reporting PASS (hardcoded-paths + root-artifacts clean) — note the missing third component — against CI's PASS (… + prose-cap clean). Same command name, different denominator.

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 origin/main, not my stale copy) against the full PR diff:

prose-cap: PASS (comment blocks within cap 2, exts .py)

Both suites still pass.

Disclosure: this push is an ordinary fast-forward on top of the reviewed head; no history rewritten.

@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.

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 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.

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 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 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

@john-the-dev
john-the-dev enabled auto-merge (squash) August 17, 2026 04:33
@github-actions

Copy link
Copy Markdown
Contributor

@cla-assistant check

@john-the-dev
john-the-dev merged commit b9770d0 into sonichi:main Aug 17, 2026
18 checks passed
john-the-dev added a commit that referenced this pull request Aug 17, 2026
…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>
john-the-dev added a commit that referenced this pull request Aug 17, 2026
…) (#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>
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.

screen-capture: TCC denial returns status:ok with a wallpaper-only frame — agent reports false vision

3 participants