Skip to content

fix(clipboard): security and correctness hardening from full audit#101

Merged
cmeans-claude-dev[bot] merged 4 commits into
mainfrom
fix/security-correctness-pr1
May 2, 2026
Merged

fix(clipboard): security and correctness hardening from full audit#101
cmeans-claude-dev[bot] merged 4 commits into
mainfrom
fix/security-correctness-pr1

Conversation

@cmeans-claude-dev
Copy link
Copy Markdown
Contributor

@cmeans-claude-dev cmeans-claude-dev Bot commented May 1, 2026

Summary

Eight fixes surfaced by a four-angle code review (security, correctness, architecture, test coverage). Three are security findings, five are correctness bugs. All include regression tests.

Security

  • Image read wire-level cap (10 MB default, env-configurable). A 100 MB clipboard bitmap previously became ~133 MB base64 in a single MCP response and could time out or drop the MCP transport. New ClipboardSizeError is raised in read_clipboard_image and surfaced to the caller via clipboard_paste. This is a wire-level guard rather than a memory-bounded read: the backend still buffers the full image before the cap fires, so the inflated MCP response is prevented but local backend memory is not bounded.
  • Image subtype allowlist. mcp.Image(format=...) now only sees png, jpeg, gif, webp, tiff, or bmp. Clipboard-controlled MIME parameter strings fall back to png.
  • Dynamic code-fence sizing. Markdown code fences in clipboard_paste (JSON, code, RTF branches) are now sized one longer than the longest backtick run inside the wrapped content, so clipboard text containing literal triple backticks cannot break out of the fence and inject Markdown.

Correctness

  • Windows list_formats dedup. Text and UnicodeText both map to text/plain; previous output reported the duplicate. Mirrors the existing macOS dedup.
  • Nested HTML tables. _TableExtractor.handle_data now gates on _table_depth == 1, so an inner-table cell's text no longer leaks into the surrounding outer cell.
  • macOS AppleScript line-length. _macos_write_typed chunks the base64 payload across multiple statements (4,000-char chunks) so it stays under AppleScript's 32,767-char per-line limit. Previously broke HTML/RTF writes larger than ~24 KB.
  • Subprocess orphan on cancel. _run_subprocess and _run_with_stdin now kill the child in a finally block. asyncio.CancelledError (BaseException) bypassed the prior timeout-only except and could leak the process on MCP client disconnect.
  • TSV phantom column. parse_tsv rejects single-row results with fewer than two non-empty cells. "word\t" (one Excel cell copied on Windows) no longer renders as a misleading 1x2 table.

Test plan

  • uv run pytest (504 passed, 6 deselected, 5 xfailed)
  • uv run ruff check src/ tests/
  • uv run ruff format --check src/ tests/
  • uv run mypy src/

CHANGELOG

Addresses eight findings from a four-angle code review (security,
correctness, architecture, test coverage). All changes are local to
the clipboard module, parser, and server tool layer.

Security:
- Cap image reads at 10 MB (configurable via MCP_CLIPBOARD_MAX_IMAGE_BYTES)
  via new ClipboardSizeError so a large clipboard bitmap cannot exhaust
  host memory or saturate the MCP transport.
- Validate image subtype against an allowlist before passing to
  mcp.Image(format=...); clipboard-controlled MIME parameter strings now
  fall back to "png" rather than flowing through verbatim.
- Size markdown code fences dynamically so clipboard text containing
  triple backticks cannot break out of the JSON, code, or RTF fence and
  inject Markdown.

Correctness:
- Deduplicate Windows list_formats output (Text and UnicodeText both map
  to text/plain), matching the existing macOS behavior.
- Stop nested HTML tables from leaking inner-cell text into the outer
  cell by gating handle_data on _table_depth == 1.
- Chunk base64 across multiple AppleScript statements to stay under
  AppleScript's 32,767-character per-line limit; previously broke for
  HTML/RTF writes larger than ~24 KB.
- Add finally-block kill in _run_subprocess and _run_with_stdin so a
  CancelledError (BaseException, not caught by the timeout-only except)
  cannot orphan the subprocess.
- Reject single-cell parse_tsv input (e.g. "word\t" from one-cell Excel
  copies on Windows) instead of presenting a 1x2 table with a phantom
  empty column.

All eight fixes have regression tests; the existing 487-test suite still
passes (502 total now), ruff and mypy clean.
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@codecov
Copy link
Copy Markdown

codecov Bot commented May 1, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

Codecov flagged two patch lines as uncovered in PR #101:
- server.py:302 (the explicit `fmt = "png"` fallback when the clipboard
  reports an image MIME outside _IMAGE_SUBTYPE_ALLOWLIST)
- clipboard.py:569 (the `b64_chunks = [""]` guard that handles empty
  content in _macos_write_typed)

The existing parameter-stripping test never reached the fallback because
base_mime_type strips parameters first, leaving "png" (in the allowlist).
Added a non-allowlisted subtype (image/x-icon) test that actually hits
line 302, plus a separate test that exercises the parameter-stripping
behavior for documentation. Added an empty-HTML write test for the b64
chunk-list fallback on macOS.
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed Ready for QA Dev work complete — QA can begin review Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Starting review. Applying QA Active. Will exercise the security caps, fence-sizing logic, AppleScript chunking, subprocess-cancel cleanup, TSV phantom-column guard, and HTML nested-table extractor against the test suite plus targeted manual reads.

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

LGTM

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA review — round 1

Eight fixes implemented and tested cleanly. Code is solid: nested-table extractor uses depth gating, AppleScript chunking stays under the 32,767-char per-line cap with empty-content fallback, subprocess cleanup uses finally with ProcessLookupError suppression, image subtype allowlist plus base_mime_type strip is the right shape, and the TSV phantom-column guard correctly distinguishes Excel single-cell copies from genuine 1×2 tables.

Findings

# Severity Finding
F1 substantive Test count drift in PR body. Test plan says now 502 passed. Actual is 504 passed, 6 deselected, 5 xfailed — the follow-up commit b718081 (test/coverage) added two tests (test_paste_image_format_strips_mime_parameters, test_macos_write_typed_empty_content) after the body was drafted, and the body was not refreshed. Update the count and check the box.
F2 substantive observation Image cap is post-read, not stream-bounded. read_clipboard_image runs the backend reader to completion before checking len(result) > _MAX_IMAGE_BYTES. On macOS the full base64 string lives in memory before b64decode; on wayland/x11/windows _run_binary's proc.communicate() reads the entire stdout into memory before the cap fires. The "drop the MCP transport" half of the claim is fully addressed (no inflated payload reaches the wire). The "exhaust memory on constrained hosts" half is overstated — a 1 GB clipboard image still gets fully buffered before the cap rejects it. Either tighten the wording in CHANGELOG.md and the PR Summary to focus on the wire-level mitigation, or bound subprocess output (e.g. read in chunks and abort early) so the constrained-hosts claim holds. Wording fix is the cheaper option and probably the right one for this PR.

Verification

Check Status
uv run pytest 504 passed, 6 deselected, 5 xfailed
uv run pytest tests/test_integration.py -m integration (live wayland) 6/6 passed
uv run ruff check src/ tests/ clean
uv run ruff format --check src/ tests/ 9 files already formatted
uv run mypy src/ clean
Manual: _safe_code_fence length math 3/4/5/7 backticks → 3/4/5/7 — correct
Manual: parse_tsv("word\t") / parse_tsv("\tword") both [] — correct
Manual: nested HTML extractor (single-level nest) [['outer', 'after']] — inner cell dropped, outer trailing text preserved
Manual: _MAX_IMAGE_BYTES default 10485760 (10 MB)
Manual: ClipboardSizeError MRO subclass of ClipboardError
6 deselected tests/test_integration.py integration marker, expected (consistent with prior reviews)
5 xfailed unchanged from prior baseline
CI gates all green (lint, typecheck, test 3.11/3.12/3.13, codecov/patch, qa-approved)

Ruff/format/mypy boxes checked; pytest box left unchecked pending F1 update.

Applying QA Failed as the final act per the workflow — F1 needs a body edit and F2 needs either a wording or implementation tweak before signoff.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Verdict: QA Failed. 2 substantive findings (1 PR-body drift, 1 changelog/PR-text overstatement). Both are wording-level fixes; no code changes required for F1, and F2 can be either a wording tightening or an implementation tightening. Once both are addressed, ping for round 2.

@cmeans cmeans added QA Failed QA found issues — needs dev attention and removed QA Active QA is actively reviewing; Dev should not push changes labels May 1, 2026
QA flagged that the original "exhaust memory on constrained hosts"
claim was overstated: read_clipboard_image fully buffers the bitmap
before the size check fires (macOS holds the base64 string before
b64decode; wayland/x11/windows _run_binary's proc.communicate() reads
the entire stdout into memory). The cap is therefore a wire-level
mitigation -- it prevents the inflated MCP response from going out --
but doesn't bound the local backend buffer. Reword to reflect that.
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA Ready for QA Dev work complete — QA can begin review and removed QA Failed QA found issues — needs dev attention Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

QA round 1 findings addressed:

F1 (test count drift): PR body updated to 504 passed, 6 deselected, 5 xfailed. Test-plan checkboxes left unchecked per the QA-ticks-them convention.

F2 (image cap wording overstatement): Took the wording-fix path. CHANGELOG ### Security entry and the PR Summary now describe the cap as a wire-level guard and explicitly note that the backend still buffers the full image before the size check, so the inflated MCP response is prevented but local backend memory is not bounded. No code change.

Commit: fc9a7c5 docs(changelog): clarify image cap is wire-level, not memory-bounded

Pinging for round 2.

@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Round 2 starting. Re-running gates against fc9a7c5 and re-verifying both F1 (test count) and F2 (image cap wording).

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA review — round 2

CHANGELOG-only commit at fc9a7c5 addresses the wording overstatement (F2) and the body's test count is now correct (F1). Verification re-run: 504 passed, ruff/format/mypy clean, CI fully green. PR body checkboxes for pytest/ruff/format/mypy all flipped.

One residual issue caught by repo-wide grep:

Findings

# Severity Finding
F3 substantive Same overstatement still in src/mcp_clipboard/clipboard.py:38-40. The CHANGELOG entry was tightened to "wire-level guard rather than a memory-bounded read" and the PR body Summary got a matching caveat, but the source comment that describes _MAX_IMAGE_BYTES still reads # Cap on image read size. A large clipboard bitmap (e.g. 100 MB uncompressed / TIFF screenshot) becomes ~133 MB base64 in a single MCP response and can / exhaust memory on constrained hosts. Configurable via env var. Same wording the CHANGELOG dropped. Update the comment to mirror the new framing — e.g. "…can drop the MCP transport. The cap is a wire-level guard; backend memory is not bounded." Doc-only fix, no code change.

Verification

Check Status
uv run pytest 504 passed, 6 deselected, 5 xfailed
uv run ruff check src/ tests/ clean
uv run ruff format --check src/ tests/ 9 files already formatted
uv run mypy src/ clean
CHANGELOG drift fix correctly applied — matches PR-body Summary caveat
Repo-wide grep constrained host|exhaust memory|wire-level|memory-bounded 1 stale hit in clipboard.py:40 (the F3 comment); CHANGELOG.md lines 111-112 are the new wording, both correct
CI gates all green (lint, typecheck, test 3.11/3.12/3.13, codecov/patch, qa-approved)

Applying QA Failed as the final act — F3 is the same wording overstatement F2 caught, just in a sibling location. Comment edit, no code, no test changes. Ping for round 3 when fixed.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Verdict: QA Failed (round 2). F1/F2 fixed. F3: same wording overstatement still in src/mcp_clipboard/clipboard.py:38-40 — comment-only fix to mirror the CHANGELOG framing.

@cmeans cmeans removed the QA Active QA is actively reviewing; Dev should not push changes label May 1, 2026
@cmeans cmeans added the QA Failed QA found issues — needs dev attention label May 1, 2026
QA round 2 (F3) caught that the source comment for _MAX_IMAGE_BYTES
still carried the old "exhaust memory on constrained hosts" framing
that the CHANGELOG and PR Summary already moved away from in fc9a7c5.
Reword the comment to match: the cap is a wire-level guard; the full
image is still buffered before the size check fires, so backend
memory is not bounded.

Repo-wide grep for "constrained host" / "exhaust memory" now empty
across src/, tests/, and CHANGELOG.md.
@github-actions github-actions Bot added Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA and removed QA Failed QA found issues — needs dev attention labels May 1, 2026
@cmeans-claude-dev
Copy link
Copy Markdown
Contributor Author

F3 addressed: the _MAX_IMAGE_BYTES source comment in clipboard.py:38-42 now mirrors the CHANGELOG/PR-Summary framing (wire-level guard, backend memory not bounded).

Verified repo-wide grep for constrained host / exhaust memory across src/, tests/, and CHANGELOG.md returns no hits.

Commit: 8a8b36c docs(clipboard): mirror wire-level wording in _MAX_IMAGE_BYTES comment

Pinging for round 3.

@github-actions github-actions Bot added Ready for QA Dev work complete — QA can begin review and removed Awaiting CI Dev complete, waiting for CI/Codecov to pass before QA labels May 1, 2026
@cmeans cmeans added QA Active QA is actively reviewing; Dev should not push changes and removed Ready for QA Dev work complete — QA can begin review labels May 1, 2026
@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Round 3 starting. Re-verifying F3 fix at 8a8b36c plus repo-wide grep for residual drift.

Copy link
Copy Markdown
Owner

@cmeans cmeans left a comment

Choose a reason for hiding this comment

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

QA review — round 3

F3 closed at 8a8b36c. The source comment for _MAX_IMAGE_BYTES now mirrors the CHANGELOG framing — "wire-level guard; backend memory is not bounded (the full image is still buffered before the size check fires)" — and a repo-wide grep for the old "constrained host" / "exhaust memory" wording comes back empty.

Verification

Check Status
uv run pytest 504 passed, 6 deselected, 5 xfailed
uv run ruff check src/ tests/ clean
uv run ruff format --check src/ tests/ 9 files already formatted
uv run mypy src/ clean
F3 wording fix applied at src/mcp_clipboard/clipboard.py:38-42, mirrors CHANGELOG.md:107-115
Repo-wide grep constrained host|exhaust memory 0 hits
Repo-wide grep wire-level|memory-bounded 2 hits (clipboard.py:40, CHANGELOG.md:111-112) — both correct
CI gates all green (lint, typecheck, test 3.11/3.12/3.13, codecov/patch, qa-approved)

All 3 findings closed across the round 1 / round 2 / round 3 cycle:

  • F1 — PR-body test count corrected to 504 ✓
  • F2 — CHANGELOG and PR-Summary wording tightened to wire-level framing ✓
  • F3 — source comment mirrors the new framing ✓

Applying Ready for QA Signoff as the final act. Awaiting maintainer QA Approved.

@cmeans
Copy link
Copy Markdown
Owner

cmeans commented May 1, 2026

[QA] Verdict: Ready for QA Signoff. F3 closed; all 3 findings across rounds 1-3 cleared. Gates re-verified at 8a8b36c (504/6/5, ruff/format/mypy clean, CI green). Awaiting maintainer QA Approved.

@cmeans cmeans added Ready for QA Signoff QA passed — ready for maintainer final review and merge QA Approved Manual QA testing completed and passed and removed QA Active QA is actively reviewing; Dev should not push changes Ready for QA Signoff QA passed — ready for maintainer final review and merge labels May 1, 2026
@cmeans-claude-dev cmeans-claude-dev Bot merged commit b043083 into main May 2, 2026
36 checks passed
@cmeans-claude-dev cmeans-claude-dev Bot deleted the fix/security-correctness-pr1 branch May 2, 2026 01:10
cmeans-claude-dev Bot added a commit that referenced this pull request May 2, 2026
#104)

## Summary

Closes the four minor audit items left after PRs #101 and #102. All four
are nice-to-haves; none are bugs. Bundled into a single PR per the audit
synthesis.

### Added
1. **`clipboard.reset_backend_cache()`** public helper. Replaces direct
mutation of the module-private `_backend` global in test fixtures
(`tests/test_server.py` and `tests/test_integration_x11.py`). Production
code has no reason to call this; the helper exists for tests that switch
backends or re-read `MCP_CLIPBOARD_BACKEND` mid-process.
2. **Direct `_has_header_row` tests** covering the `len(rows) < 2`
early-return path, the uniform-text-no-header path, and a positive
text-header-over-numeric-data case. Previously exercised only indirectly
via `format_table` JSON output.
3. **Padding-loop regression test** for
`clipboard_paste(include_schema=True)` when the data rows are wider than
the header row. Asserts synthetic `Col 3` / `Col 4` labels are emitted
by the padding loop at `server.py:222-228`.
4. **Env-var validation tests** for `MCP_CLIPBOARD_MAX_WRITE_BYTES` and
`MCP_CLIPBOARD_MAX_IMAGE_BYTES`. Both vars are parsed via
`int(os.environ.get(...))` at module import; non-integer values raise
`ValueError` before anything else runs. Tests exercise each in a
subprocess so the partial-import state stays isolated from the
in-process module cache.

## Implementation note

The env-var-validation tests originally tried `importlib.reload()`
in-process, which broke 21 other tests because their
`pytest.raises(ClipboardError)` calls compared against the pre-reload
class object, while the reloaded module raised the new class. Switched
to subprocess-based assertions that capture stderr and verify both the
`ValueError` traceback and the offending value appear.

## Test plan

- [x] `uv run pytest` (511 passed, 11 deselected, 5 xfailed)
- [x] `uv run ruff check src/ tests/`
- [x] `uv run ruff format --check src/ tests/`
- [x] `uv run mypy src/`

## CHANGELOG

- [x] `## [Unreleased]` updated with four `### Added` entries
referencing #104.

Co-authored-by: cmeans-claude-dev[bot] <272174644+cmeans-claude-dev[bot]@users.noreply.github.com>
@cmeans-claude-dev cmeans-claude-dev Bot mentioned this pull request May 2, 2026
6 tasks
cmeans-claude-dev Bot added a commit that referenced this pull request May 3, 2026
Bump pyproject.toml 2.2.1 -> 2.3.0 and convert the [Unreleased] block into [2.3.0] - 2026-05-02. A fresh empty [Unreleased] section sits above for the next cycle.

13 PRs aggregated since v2.2.1: #88, #92, #93, #94, #95, #96, #98, #99, #100, #101, #102, #103, #104.

Tag-push (v2.3.0) after merge triggers .github/workflows/publish.yml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

QA Approved Manual QA testing completed and passed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant