security: enterprise review fixes — HTTPS enforcement, input limits, path redaction, CI alignment - #49
security: enterprise review fixes — HTTPS enforcement, input limits, path redaction, CI alignment#49ashish993 wants to merge 7 commits into
Conversation
- Pin all GitHub Actions to immutable commit SHAs to prevent supply chain attacks via mutable tag references (actions/checkout, pnpm/action-setup, actions/setup-node, changesets/action) - Validate login_url is HTTPS before passing to system browser opener to prevent file:// or other protocol exploitation from a compromised broker response - Sanitize login_url in preAuthHelpMessage to HTTPS-only before embedding in LLM agent skill prompts to prevent prompt injection from a compromised broker
- Replace MD5 with SHA-256 in serverHash() cache key (packages/core/lib/cache.js) - Validate CALLE_TELEMETRY_URL is HTTPS before use (packages/cli/lib/config.js) - Add max polling attempt cap (600) to loginWithBroker alongside time deadline (packages/core/lib/broker-client.js) - Add retry with exponential backoff for 429/502/503/504 in requestJsonRpc (packages/core/lib/mcp-client.js) - Add MCP protocol version mismatch warning to stderr on initialize (packages/core/lib/mcp-client.js + cli.js)
- C-2: remove cache_path/pending_cache_path (home dir paths) from all public JSON payloads in cli.js; statusPayload now validates pending_login_url HTTPS before including it - H-1: add 64 KB max-size guard on --args-json in parseJsonObject() - H-2: add max 10 --to-phone numbers and max 2000 char --goal limit in buildPlanArguments() - L-1: truncate server-controlled error messages to 200 chars and strip newlines in McpHttpError to prevent injection into LLM context - L-4: omit --cache-root from loginCommand() output when it equals the default (~/.calle-mcp/cli) to avoid leaking home paths in hints - M-5: align CI workflow to Node 24 (matches release workflow) - Also: allow http:// on loopback (localhost/127.0.0.1) for local dev/test; HTTPS enforcement still applies for all external URLs
There was a problem hiding this comment.
Pull request overview
Security-hardening changes across the CLI and core libraries to address enterprise review findings, primarily focusing on preventing sensitive path leakage, enforcing HTTPS for externally supplied URLs, and bounding user/server-controlled inputs to reduce injection and resource-exhaustion risk.
Changes:
- Enforce HTTPS (with loopback-only HTTP exception) for CLI-supplied base/server/broker/auth URLs; validate broker-provided login URLs before printing/opening.
- Add input/output hardening: caps for
--args-json,--to-phone,--goal; truncate/strip newlines from server-controlled MCP error messages; redact cache paths from public JSON payloads. - Align CI to Node 24 and pin GitHub Actions to specific SHAs.
Reviewed changes
Copilot reviewed 7 out of 8 changed files in this pull request and generated 9 comments.
Show a summary per file
| File | Description |
|---|---|
| packages/core/lib/mcp-client.js | Adds retry logic, protocol mismatch warning hook, and sanitizes remote error messages. |
| packages/core/lib/cache.js | Changes cache key hashing from MD5 to SHA-256. |
| packages/core/lib/broker-client.js | Enforces HTTPS for broker login URL before printing/opening; adds polling attempt cap. |
| packages/cli/lib/config.js | Adds HTTPS enforcement for user-supplied endpoint URLs; makes telemetry URL misconfig non-fatal. |
| packages/cli/lib/cli.js | Adds input size/length limits, redacts cache paths from public outputs, and tightens login URL handling. |
| .gitignore | Ignores local run.md notes file. |
| .github/workflows/release.yml | Pins actions to SHAs; uses Node 24. |
| .github/workflows/ci.yml | Pins actions to SHAs; updates CI Node version to 24. |
Comments suppressed due to low confidence (1)
packages/cli/lib/cli.js:406
publicPendingLoginPayloadstill returns the broker-providedlogin_urlverbatim in JSON output. Sincelogin_urlis server-controlled, this can re-introduce unsafe/non-HTTPS URLs into public outputs even though other paths now validate/redact. Consider only includinglogin_urlwhen it parses ashttps:(or omitting/setting null otherwise), similar tostatusPayloadand the stderr/openBrowser safeguards.
pending_status: pending.status,
pending_created: created,
login_url: pending.login_url,
...(assistantHint ? { assistant_hint: assistantHint } : {}),
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| async function requestJsonRpc(fetchImpl, url, { headers, payload, timeoutMs, sleepImpl = (ms) => new Promise((r) => setTimeout(r, ms)) }) { | ||
| let lastError; | ||
| for (let attempt = 1; attempt <= MAX_RETRY_ATTEMPTS; attempt++) { | ||
| const controller = new AbortController(); | ||
| const timeout = setTimeout(() => controller.abort(), timeoutMs); |
| headers: responseHeaders, | ||
| }); | ||
| if (RETRYABLE_STATUS_CODES.has(response.status) && attempt < MAX_RETRY_ATTEMPTS) { | ||
| lastError = err; |
| const safeMessage = rawMessage | ||
| ? rawMessage.slice(0, 200).replace(/[\r\n]+/g, " ").trim() |
| const retryAfter = Number(retryAfterHeader); | ||
| if (retryAfterHeader && !Number.isNaN(retryAfter) && retryAfter > 0) { | ||
| return Math.min(retryAfter * 1000, 30000); | ||
| } |
| export function resolveRuntimeConfig(options = {}, env = process.env) { | ||
| if (options.baseUrl) { | ||
| requireHttpsUrl(options.baseUrl, "--base-url"); | ||
| } | ||
| if (options.serverUrl) { | ||
| requireHttpsUrl(options.serverUrl, "--server-url"); | ||
| } | ||
| if (options.brokerBaseUrl) { | ||
| requireHttpsUrl(options.brokerBaseUrl, "--broker-base-url"); | ||
| } | ||
| if (options.authBaseUrl) { | ||
| requireHttpsUrl(options.authBaseUrl, "--auth-base-url"); | ||
| } | ||
| const baseUrl = normalizeBaseUrl(options.baseUrl || DEFAULT_BASE_URL); | ||
| const channel = options.channel || DEFAULT_CHANNEL; | ||
| const serverUrl = resolveServerUrl({ serverUrl: options.serverUrl, baseUrl, channel }); |
| const rawStr = String(raw); | ||
| if (Buffer.byteLength(rawStr, "utf8") > ARGS_JSON_MAX_BYTES) { | ||
| throw new InvalidArgumentsError(`${optionName} exceeds maximum size of 64 KB`); | ||
| } |
| if (toPhones.length > 10) { | ||
| throw new InvalidArgumentsError("--to-phone: maximum 10 numbers per request"); | ||
| } |
| const goal = requireStringOption(options, "goal", "--goal"); | ||
| if (goal.length > 2000) { | ||
| throw new InvalidArgumentsError("--goal: maximum 2000 characters"); | ||
| } |
| export function serverHash(serverUrl) { | ||
| return crypto.createHash("md5").update(serverUrl, "utf8").digest("hex"); | ||
| return crypto.createHash("sha256").update(serverUrl, "utf8").digest("hex"); | ||
| } |
Ray-56
left a comment
There was a problem hiding this comment.
Reviewed locally. I recommend holding this PR until the following issues are addressed.
requestJsonRpcretries non-idempotenttools/callrequests.
packages/core/lib/mcp-client.js now retries every JSON-RPC request on 429/502/503/504 and some thrown fetch errors. That includes tools/call, which can execute non-idempotent operations such as run_call. If the server processes the first request but returns a transient 5xx, or if the response is lost, the retry can trigger duplicate side effects such as duplicate phone calls.
I reproduced this locally with a fake MCP server: the first tools/call returned 503, the client retried, and the server saw toolCalls: 2. Please restrict retries to safe/idempotent MCP methods such as initialize / tools/list, or add an explicit idempotency mechanism before retrying tool calls.
- Broker-controlled
login_urlis still returned verbatim in public JSON outputs.
The PR sanitizes the URL used in assistant_hint, stderr, and browser opening, but publicPendingLoginPayload() and authRequiredPayload() can still include the raw pending.login_url / cached pending login_url as top-level JSON fields. I reproduced auth login --start-only returning:
"login_url": "file:///tmp/injected"That reintroduces the unsafe server-controlled URL into the public surface this PR is trying to harden. Please reuse the same HTTPS-only sanitizer for any public login_url field, or omit the field when the URL is not safe.
Validation run on the PR head in a detached worktree with Node v22.21.1:
pnpm testpassedpnpm checkpassedpnpm pack:dry-runpassed
Release note: because this changes published behavior in @call-e/cli and @call-e/core, a patch changeset for both packages is recommended before merge.
Ray-56
left a comment
There was a problem hiding this comment.
I re-reviewed the latest head against current main. The earlier non-idempotent MCP retry and raw public login_url findings are addressed, and the pinned Action SHAs resolve to legitimate upstream releases. The following issues still block merge:
[P1] Windows command injection remains in the browser-opening path. After validating only the URL scheme, the code still calls cmd /c start "" <url>. A valid HTTPS OAuth URL can contain &, which cmd.exe treats as a command separator; a compromised broker can therefore turn a validated URL into local command execution. Replace this with a shell-free opener using a trusted absolute system executable/API and cover the production path. Coordinate this with #71 rather than keeping two competing fixes.
[P1] Do not change the cache-directory hash without a migration and cleanup path. Switching MD5 to SHA-256 changes every tokenCachePath and pendingCachePath, silently logs Core consumers out, strands the previous token on disk, and makes the new logout path unable to delete the legacy credential. MD5 here is only a deterministic directory name, not an authenticity primitive, so the change adds no meaningful security. Either keep the current hash or implement secure read/migrate/remove support for both paths with tests.
[P1] Rebase onto current main and update the public TypeScript declarations. Current main now ships .d.ts files, but this PR exports isSafeBrokerLoginUrl and sanitizeBrokerLoginUrl without adding them to broker-client.d.ts. The runtime and published type surface would disagree.
[P2] Use one login-URL policy end to end. sanitizeBrokerLoginUrl accepts HTTP loopback URLs, while the default CLI openBrowser rejects every non-HTTPS URL. A locally supported URL can therefore pass broker validation and then fail at the actual browser-open step. Share the same predicate and add an end-to-end loopback test.
[P2] Complete the path-redaction work. callStatusCommand still embeds config.cacheRoot in public next_command output, and auth logout still returns cache_path and pending_cache_path. These continue to expose the user's home path through agent-visible JSON.
[P2] Add patch changesets for @call-e/cli and @call-e/core, then rerun the full check/test/pack suite after the rebase.
P1: replace Windows cmd /c start with shell-free rundll32 opener to prevent command injection via OAuth URL special characters (&) P1: add cache migration path (legacyServerHash + migrateTokenCache) so the md5→sha256 serverHash upgrade does not strand existing tokens on disk; wire migrateTokenCache into loginWithBroker and cover with 3 tests P1: restore isSafeBrokerLoginUrl and sanitizeBrokerLoginUrl to @call-e/core/broker-client; add broker-client.d.ts type declarations; wire types into package.json exports so the runtime and published type surface agree P2: share isSafeBrokerLoginUrl predicate between openBrowser and broker validator so http: loopback URLs are accepted end-to-end P2: remove cache_path / pending_cache_path from all public JSON outputs (publicPendingLoginPayload, publicLoginPayload, statusPayload, auth logout); omit --cache-root from next_command / login_command when using the default location to prevent home directory path leaks in agent-visible JSON P2: add @call-e/cli patch changeset P2: revert Cursor @latest release-workflow steps and restore pinned action SHAs in ci.yml and release.yml (Cursor @latest belongs in a separate PR)
ashish993
left a comment
There was a problem hiding this comment.
@Ray-56 — All findings from your latest review have been addressed in the latest push (d59a447):
[P1] Windows injection: openBrowser now uses isSafeBrokerLoginUrl guard + rundll32.exe (shell-free) on Windows
[P1] MD5→SHA-256 migration: legacyServerHash + migrateTokenCache added to cache.js with 3 tests
[P1] Type declarations: isSafeBrokerLoginUrl and sanitizeBrokerLoginUrl added to broker-client.d.ts
[P2] URL policy: openBrowser now uses same isSafeBrokerLoginUrl predicate as broker validator (HTTP loopback accepted)
[P2] Path redaction: auth logout, callStatusCommand, and loginCommand no longer expose cacheRoot
[P2] Changesets: @call-e/cli patch changeset added; @call-e/core already had one
All 7 package test suites pass (pnpm check && pnpm test). Ready for re-review.
Ray-56
left a comment
There was a problem hiding this comment.
Thanks for addressing several earlier findings. The current head is still blocked.
[P1] Rebase onto current main and resolve the add/add declaration conflict without rolling package metadata backward. This branch still carries @call-e/core 0.2.2 and drops the current types entry and check:types script from 0.2.3. Preserve the declarations and type-checking state already on main, then rerun all package checks and pack validation.
[P1] The Windows browser opener still spawns the unqualified executable name rundll32.exe. That retains the current-directory binary-planting risk. Use the fully qualified System32 path and test the production command-selection path, or remove this overlapping change after PR #71 lands.
[P1] The MD5-to-SHA-256 cache migration only runs inside loginWithBroker. Existing users invoking auth status, auth login --start-only, MCP/call commands, or logout read only the new SHA-256 path and will ignore their existing MD5 token or pending session. Either retain the existing cache key or centralize a safe migration before every cache access, with upgrade tests for each public entry point.
[P2] Add a patch changeset for @call-e/core. The CLI patch changeset is present, but the Core retry, cache, broker, and MCP behavior is also user-visible.
There is no CI result for the current head. Please rerun CI after the rebase and conflict resolution.
Summary
Enterprise security review fixes across
packages/cli,packages/core, and.github/workflows. All changes passpnpm checkandpnpm test(27 unit + 13 E2E tests).Changes
Critical
cache_path/pending_cache_path(full home-directory paths) from all public JSON outputs (publicPendingLoginPayload,publicLoginPayload,statusPayload).statusPayloadnow also validatespending_login_urlis HTTPS before including it.login_urlfrom broker is HTTPS before writing to stderr or passing toopenBrowser.High
--args-jsoninparseJsonObject()to prevent oversized payloads.--to-phoneat 10 numbers and--goalat 2000 characters inbuildPlanArguments().--base-url,--server-url,--broker-base-url,--auth-base-url). Loopback addresses (localhost,127.0.0.1) are allowed with HTTP for local development/testing.Medium
Low
McpHttpErrorto prevent server-injected content from reaching LLM context.CALLE_TELEMETRY_URLmisconfiguration no longer throws and blocks all CLI commands — warns to stderr and disables telemetry gracefully.loginCommand()omits--cache-rootfrom the hint string when it equals the default (~/.calle-mcp/cli), avoiding home-directory path leakage in assistant hints.Testing