fix(mcp): sanitize every terminal path that prints API-controlled text - #6396
Conversation
sanitizePlainTextTerminalOutput strips ANSI escapes and control characters, but guarded exactly one call site: validateConfigCli's warning loop. Every other command that printed server-controlled free text wrote it straight to the terminal, so a hostile API response could repaint the screen, erase the lines above it, or park a convincing fake verdict beside the real one. The terminal cannot tell this CLI's text from the payload's. Sanitized: slop-risk and issue-slop's findings and score line, decision-pack's summary and rerunGuidance, repo-decision's nextActions and rerunGuidance, maintain status/queue's action fields, and doctor's check output. issue-slop is the sharpest of these -- the body it assesses is routinely a third party's issue text, so hostile input is the expected case, not a hypothetical one. Both slop commands' score lines are sanitized alongside their findings: the whole payload is the API's, so leaving `band` raw would have kept the exact command exploitable by the exact response the findings are protected from. Two of the listed paths are deliberately left alone, verified rather than assumed. decisionPackToolSummary interpolates the user's own --login plus payload.freshness, and freshness only reaches the string inside an equality guard against "stale"/"rebuilding", so the API cannot route text of its own through it. repoDecisionToolSummary takes only --login and --repo, both typed by the user. Neither is reachable by a payload. cache.rerunGuidance IS sanitized, though its only in-repo producer is a local literal: getDecisionPackWithCache returns the API payload verbatim on the success path, so a response carrying its own `cache` object prints straight through. The local literal is only the fallback. --json stays unsanitized by design: JSON.stringify escapes U+001B, so an escape sequence cannot survive into the printed document, and stripping bytes there would corrupt the contract callers parse. A test pins that reasoning. doctor's details already pass through sanitizeDiagnosticText, but that redacts tokens and local paths and is indifferent to escapes -- so the terminal pass is applied at the print boundary, covering every check source. Tests drive each real command against a fixture answering with a colour + cursor-up + line-erase + OSC + NUL payload. All six fail against the pre-fix CLI; the --json test passes on both sides, which is the point of it. Closes JSONbored#6261
|
Superagent didn't find any vulnerabilities or security issues in this PR. |
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #6396 +/- ##
=======================================
Coverage 95.60% 95.60%
=======================================
Files 599 601 +2
Lines 47301 47310 +9
Branches 15046 15050 +4
=======================================
+ Hits 45223 45232 +9
Misses 1291 1291
Partials 787 787
Flags with carried forward coverage won't be shown. Click here to find out more. |
|
Tip ✅ LoopOver review result - approve/merge recommendedReview updated: 2026-07-16 06:54:05 UTC
Review summary Nits — 5 non-blocking
Decision drivers
Context & advisory signals — never blocks the verdict
Review context
Contributor next steps
Signal definitions
🧪 Chat with LoopOverAsk LoopOver a question about this PR directly in a comment — grounded only in the same cached, public-safe facts shown above, never a new claim.
Full command reference: https://loopover.ai/docs/loopover-commands 🧪 Experimental — new and may change. 🟩 Safe / merged · 🟦 Advisory · 🟨 Held for review · 🟥 Blocked / closed 💰 Earn for open-source contributions like this. Gittensor lets GitHub contributors earn for the work they already do — register to start earning →. Checked by LoopOver, a quiet PR intelligence layer for OSS maintainers.
|
Summary
sanitizePlainTextTerminalOutputstrips ANSI escapes and control characters, but guarded exactly one call site:validateConfigCli's warning loop. Every other command that printed server-controlled free text wrote it straight to the terminal. A hostile API response could therefore repaint the screen, erase the lines above it, or park a convincing fake verdict beside the real one — the terminal cannot tell this CLI's text from the payload's.slop-riskfinding.title,finding.detail, score line (slopRisk,band)issue-slopdecision-packpayload.summary,cache.rerunGuidancerepo-decisiondecision.nextActions[],cache.rerunGuidancemaintain status/queueaction.id,actionClass,pullNumber,reason,summary,targetdoctorcheck.status,check.name,check.detail,check.remediationissue-slopis the sharpest of these: the body it assesses is routinely a third party's issue text, so hostile input is the expected case, not a hypothetical one.Both slop commands' score lines are sanitized alongside their findings. The whole payload is the API's (
apiPost), so leavingbandraw would have kept the exact command exploitable by the exact response the findings are being protected from.What I verified rather than assumed
The issue asked me to confirm, not assume, before skipping any field. Three findings:
decisionPackToolSummary— skipped, safe. It interpolateslogin(the user's own--login/env value) andpayload.freshness, and freshness only reaches the string inside an equality guard against the literals"stale"/"rebuilding". The API cannot route text of its own choosing through it.repoDecisionToolSummary— skipped, safe. It takes onlyloginandrepoFullName, both typed by the user on their own command line. No payload text reaches it.cache.rerunGuidance— sanitized, and this one surprised me. Its only in-repo producer is a local literal, which suggested it was safe. ButgetDecisionPackWithCachereturns the API payload verbatim on the success path, so a response carrying its owncacheobject prints straight through. The local literal is only the fallback path. Sanitized accordingly.Also worth recording:
doctor's details already pass throughsanitizeDiagnosticText, but that redacts tokens and local paths and is indifferent to escape sequences — the two are orthogonal. So the terminal pass is applied at the print boundary, where it covers every check source (API errors, npm-registry errors, compatibility reports) at once.Why
--jsonis deliberately left aloneJSON.stringifyescapes U+001B (and the rest of U+0000–U+001F) into its six-character escaped form, so an escape sequence cannot survive into the printed document — and stripping bytes there would corrupt the machine-readable contract callers parse. Rather than trust that reasoning, a test pins it: it asserts--jsonemits no raw escape and that the payload still round-trips byte-for-byte.Validation
test/unit/mcp-cli-terminal-sanitization.test.ts— 7/7 pass. Each test drives a real command against a fixture answering with a colour + cursor-up + line-erase + OSC title-set + NUL payload.grepreport "binary file matches", which is the bug demonstrating itself.) The--jsontest passes on both sides, which is exactly its purpose.--body-filenon-regular inputs) are pre-existing on cleanmainwith my change absent (Windows symlink restriction; they pass on CI's Linux).npm run typecheck— 0 errors.npm run build:mcp— passes.git diff --check— clean. Rebased on latestmain, no base conflict.Coverage
No patch surface: coverage is collected over
src/**,packages/loopover-engine/src/**, andpackages/loopover-miner/lib/**. This PR touches onlypackages/loopover-mcp/**(not collected) andtest/**(ignored).Scope
packages/,test/).validateConfigCli's already-sanitized path is untouched, as required.site/,CNAME, orlovablechanges.Safety
validateConfigClialready had.Closes #6261