Skip to content

Release 0.10.0: close injection vector, confine writes, cut tools/list 66% - #49

Merged
soodoku merged 4 commits into
mainfrom
release/0.10.0
Jul 28, 2026
Merged

Release 0.10.0: close injection vector, confine writes, cut tools/list 66%#49
soodoku merged 4 commits into
mainfrom
release/0.10.0

Conversation

@soodoku

@soodoku soodoku commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

Three commits. The first is the one worth reading closely.

Security

packages was an injection vector. execute_r_analysis interpolated caller-supplied strings straight into library(...) lines, and validate_r_code never received them. Verified executing before the fix:

packages = ["stats); RMCP_INJECTION_MARKER <- TRUE; library(utils"]
# -> injected code ran: True

That routed around the package allowlist, DANGEROUS_PATTERNS, and every OPERATION_CATEGORIES approval gate. It mattered specifically because it holed the consent mechanism: a model refused OPERATION_APPROVAL_NEEDED for system(...) in r_code could put it in packages and get no prompt. Entries must now be bare R identifiers and pass the allowlist or session approval.

File writes are now confined to the VFS roots. VFS.write_file had zero production callers, so the VFS had never enforced anything against R — including for execute_r_analysis, where grant_write was bookkeeping only. The three fileops writers and six visualization tools now validate file_path before it reaches R. Fails open when no VFS is configured, so embedders are unaffected.

approve_operation no longer disables read-only globally, granting write access to the requested directory instead.

Correctness

  • rmcp serve never configured structlog, whose default factory writes to stdout — corrupting the stdio JSON-RPC stream.
  • R timeouts orphaned the subprocess: asyncio.wait_for sat inside a TaskGroup, so the timeout arrived as a BaseExceptionGroup the sibling handler couldn't catch, and proc.kill() never ran.
  • A module-level asyncio.Semaphore bound to whichever event loop first made a caller wait, then raised "bound to a different event loop" everywhere else. This was the cause of the long-standing test_concurrent_requests failure — 10 requests minus 4 permits was exactly the 6 failures observed.
  • load_example returned a fallback dict missing required output fields, so every real error surfaced as 'statistics' is a required property. That was masking the bug above.
  • timeout_seconds was declared and ignored; CORS was pinned to *; approvals didn't survive to the next request; approve_operation/approve_r_package were never registered at all.

Context

tools/list is down ~66%, 103,939 → ~35,500 chars (~26k → ~9k tokens), while gaining two tools. outputSchema is no longer advertised — it was 54% of the payload and results are still validated against it server-side.

Tests

Several tests wrapped their body in except Exception and returned False. AssertionError is an Exception and pytest discards return values, so they passed regardless of outcome — confirmed by injecting assert False into test_business_analyst_scenario and watching it pass. Fixing them surfaced a real wrong assertion: test_logistic_regression_separation_warning expected a model_summary key the tool has never returned.

279 passing, 0 failing (previously 255 passing with 1 failure).

Removed

write_csv's append — R's write.csv refuses it and truncates, so the schema promised behaviour that silently lost data. Plus ~1,700 lines never wired into any execution path: package_tiers.py, package_security.py (the documented "4-tier security system"), discovery.py, r_session.py.

Note on CI

python-publish.yml has no needs: and no test gate, and ci.yml doesn't trigger on tags — so this PR is the only place CI validates these changes before they ship. Worth gating separately.

🤖 Generated with Claude Code

soodoku and others added 4 commits July 27, 2026 17:59
Security:
- `packages` was an injection vector. execute_r_analysis interpolated
  caller strings straight into library(...) lines and validate_r_code
  never saw them, so "stats); system('...'); library(utils" executed
  arbitrary R past the allowlist, the dangerous-pattern block and every
  approval category. Entries must now be bare R identifiers and pass the
  allowlist or session approval.
- File writes are confined to the VFS roots via a new
  VFS.validate_write_path(), called by the three fileops writers and the
  six visualization tools before the path reaches R. VFS.write_file had
  no production callers, so the VFS had never enforced anything.
  Fails open with no VFS configured, so embedders are unaffected.
- approve_operation grants write access to the requested directory
  instead of setting vfs.read_only = False for the whole process.

Correctness:
- `rmcp serve` never configured structlog, whose default factory writes
  to stdout, corrupting the stdio JSON-RPC stream.
- R timeouts left the subprocess running: asyncio.wait_for sat inside a
  TaskGroup, so the timeout arrived as a BaseExceptionGroup that the
  sibling handler could not catch and proc.kill() never ran.
- The module-level asyncio.Semaphore bound to whichever event loop first
  made a caller wait, then raised "bound to a different event loop"
  everywhere else. One limiter per running loop now.
- load_example returned a fallback dict missing required output fields,
  so every real error surfaced as "'statistics' is a required property".
- execute_r_analysis's timeout_seconds was declared and ignored.
- CORS was always "*": configured origins were never forwarded, and the
  localhost wildcards are unmatchable by allow_origins regardless. They
  compile to allow_origin_regex now.
- Approvals live on LifespanState, not the per-request Context, so they
  survive to the next request as documented. approve_operation and
  approve_r_package are now registered and reachable at all.
- Oversized results are capped at 32 retained entries, and an expired
  rmcp://data/{id} link explains itself instead of raising KeyError.

Context:
- tools/list is down ~66%, 103,939 -> ~35,500 chars, while gaining two
  tools. outputSchema is no longer advertised (54% of the payload;
  results are still validated against it) and the 46 templated
  descriptions are one-liners. The initialize instructions blob went
  2,789 -> 922 chars and stopped duplicating the catalogue.

Removed:
- write_csv's `append`: write.csv refuses it and truncates, so the
  schema promised behaviour that silently lost data.
- ~1,700 lines never wired into any execution path: package_tiers.py,
  package_security.py (the documented "4-tier security system"),
  discovery.py, r_session.py.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Several tests wrapped their body in `except Exception` and returned
False. AssertionError is an Exception and pytest discards return values,
so those tests passed no matter what they computed. Injecting
`assert False` into test_business_analyst_scenario confirmed it passed.

- test_realistic_scenarios.py: drop the swallowing wrapper from all four
  tests, and delete the dead run_all_scenarios() manual runner, which
  depended on the return values and printed "ALL SCENARIOS PASSED!".
- test_r_error_handling.py: narrow nine handlers from Exception to
  RExecutionError so assertions inside the try propagate. This surfaced
  a real wrong assertion: test_logistic_regression_separation_warning
  expected a `model_summary` key the tool has never returned. Replaced
  with a check of the inflated standard errors separation actually
  produces.
- test_excel_plotting_scenarios.py: test_other_problematic_tools had no
  assert at all; it now collects failures and asserts on them.
- test_all_tool_schemas.py: additionalProperties test asserted True in
  both branches. Its fixture was also invalid (passed `variables` when
  t_test requires `variable`), so it was failing on a missing required
  property and calling that a pass.

Promote PytestReturnNotNoneWarning to an error. Note this only guards
sync tests -- pytest-asyncio unwraps coroutines, so it would not have
caught the four above.

New cover: packages injection and allowlist bypass, per-loop semaphore
behaviour across successive event loops, and large-data store eviction.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Minor rather than patch: outputSchema left the wire, file writes are
confined, write_csv's `append` is gone and two tools were added.

The tool count was stated four ways -- 44 in `rmcp --help`, 52 in the
PyPI description, 53 in the install docs, 54 in the READMEs. Counts rot
and change no reader's decision, so they are dropped from the prose
rather than corrected.

Removed claims that were simply false:
- RMCP_DISABLE_OPERATION_APPROVAL and RMCP_AUTO_APPROVE_FILE_OPERATIONS
  appear nowhere in the code.
- context.approval_state.approve_category() does not exist; approvals
  live on LifespanState.
- The validate_r_code example had the arguments reversed, awaited a sync
  function, and destructured a dict from a function returning a tuple.
- `rmcp http` is not a command.
- "Zero Skipped Tests" -- there are 30+ dependency-gated skips.
- The "Secure" feature bullet advertised the permission tiers deleted in
  166f33f. It now names the guardrails that exist and says plainly they
  guard against mistakes, not adversaries, since RMCP runs R as the
  invoking user.

Also: broken references (CONTRIBUTING.md, tests/e2e/, black), Python
3.10+ against a >=3.11 floor, README package counts contradicting
get_package_categories(), a stray committed "# Test comment", and an
`echo ... | rmcp start` diagnostic that CHANGELOG 0.9.1 documents as a
broken pattern.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
docker/metadata-action only populates {{branch}} for branch pushes. On a
pull_request it is empty, so `type=sha,prefix={{branch}}-` produced the
tag "-<sha>", which docker rejects:

  ERROR: failed to build: invalid tag
  "ghcr.io/finite-sample/rmcp/rmcp-ci:-798eb64": invalid reference format

Latent since the tag template was written; it only fires when a PR
touches a Docker-relevant path, so most PRs skip the build and never hit
it. The consequence is worse than a red X: r-testing, docker-scenarios
and production-container-tests all depend on this job, so an R-affecting
PR silently skips every R integration test.

Emit a valid pr-<number> tag on PRs and gate the {{branch}} sha tag to
non-PR events.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 59f85f50ae

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread rmcp/core/context.py
Comment on lines +166 to +169
vfs = getattr(self.lifespan, "vfs", None)
if vfs is None:
return None
return vfs.validate_write_path(path)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Pass the validated path to R

When a write target contains ~, validate_write_path() validates Path(path).resolve() but the writer tools discard that returned path and pass the original string to R. For example, with the repository as an allowed root, Python treats ~/output.csv as <repo>/~/output.csv, while R expands it to the process user's home directory; in read-write mode, or after granting the corresponding apparent directory, a named writer can therefore escape every allowed root. Replace the tool parameter with the validated absolute path (or otherwise normalize using R-compatible expansion) before starting R.

Useful? React with 👍 / 👎.

Comment thread rmcp/tools/flexible_r.py
}

context._approved_operations[operation_type][specific_operation] = approval_data
approvals[operation_type][specific_operation] = approval_data

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Confine approved flexible-R file writes

Once a file_operations approval is stored here, validate_r_code() authorizes the operation solely by function name, while execute_r_analysis() never calls require_write_path() for paths embedded in the R source. Thus approving write.csv or ggsave—even with a directory grant—allows a later script to write to any filesystem path accessible to the server process, bypassing the VFS confinement added to the named tools. File-operation approval needs to enforce the approved destination or run the subprocess in an actual filesystem sandbox.

Useful? React with 👍 / 👎.

Comment thread rmcp/core/context.py
Comment on lines +58 to +61
# Operation approvals granted by the user. Lives here rather than on Context
# because a fresh Context is built per request -- approvals must outlive it.
approved_operations: dict[str, dict[str, Any]] = field(default_factory=dict)
approved_packages: set[str] = field(default_factory=set)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Scope approvals to the requesting HTTP session

On Streamable HTTP deployments with multiple clients or MCP sessions, LifespanState is shared by the entire server, so an approval granted by one client immediately authorizes package loads, file operations, and even system calls for every other client until restart. This contradicts the advertised session-only scope and creates a cross-client privilege leak; approvals need to be keyed by transport/session identity rather than stored in a single lifespan-wide map and set.

Useful? React with 👍 / 👎.

Comment thread rmcp/tools/flexible_r.py
Comment on lines 595 to +598
"action": "denied",
"scope": "none",
"message": message,
"approved_operations": {
k: list(v.keys())
for k, v in getattr(context, "_approved_operations", {}).items()
},
"approved_operations": {k: list(v.keys()) for k, v in approvals.items()},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Remove persistent approval when an operation is denied

If an operation was approved earlier in the server lifetime and is later called with action="deny", this branch reports success and action="denied" but never removes specific_operation from approvals; the returned approved_operations even continues to list it, and subsequent R code remains authorized. The analogous package-denial branch also leaves the package in approved_packages, so both denial paths should revoke any existing persistent approval.

Useful? React with 👍 / 👎.

@soodoku
soodoku merged commit 0ca14eb into main Jul 28, 2026
12 checks passed
@soodoku
soodoku deleted the release/0.10.0 branch July 28, 2026 01:18
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.

1 participant