diff --git a/.changeset/redact-plan-confirm-token.md b/.changeset/redact-plan-confirm-token.md new file mode 100644 index 0000000..1c99327 --- /dev/null +++ b/.changeset/redact-plan-confirm-token.md @@ -0,0 +1,5 @@ +--- +"@call-e/cli": patch +--- + +Add opt-in `--redact-confirm-token` for `call plan` and let `call run --plan-id` reuse the private cache. diff --git a/packages/cli/docs/cli-reference.md b/packages/cli/docs/cli-reference.md index 07b28e6..3069c7e 100644 --- a/packages/cli/docs/cli-reference.md +++ b/packages/cli/docs/cli-reference.md @@ -153,9 +153,9 @@ subcommand are rejected instead of being silently ignored. | `calle mcp config` | Print MCP client configuration JSON. | None | | `calle mcp tools` | List tools from the configured MCP server. | None | | `calle mcp call ` | Call an arbitrary MCP tool. | `` | -| `calle call plan` | Plan a phone call through `plan_call`. | `--to-phone`, `--goal` | +| `calle call plan` | Plan a phone call through `plan_call`. Prints `confirm_token` by default. `--redact-confirm-token` (or `CALLE_REDACT_CONFIRM_TOKEN=1`) hides it, sets `has_confirm_token`, and stores the pair in the private cache. | `--to-phone`, `--goal` | | `calle call start` | Plan and run a phone call without printing confirmation data. | `--to-phone`, `--goal` | -| `calle call run` | Run a planned phone call, then fetch status once. | `--plan-id`, `--confirm-token` | +| `calle call run` | Run a planned phone call, then fetch status once. `--confirm-token` is required unless `call plan --redact-confirm-token` stored that `plan_id`. Cache hits report `confirm_token_source: "private_cache"`. | `--plan-id`, `--confirm-token` | | `calle call recover` | Safely repeat an uncertain `run_call` with its original private confirmation data. | `--recovery-id` | | `calle call status` | Query a call run through `get_call_run`. | `--run-id` | | `calle regions list` | Print the supported regions and languages documentation URL. | None | @@ -264,13 +264,18 @@ network requests or output. | `--language` | Text | None | `call plan`, `call start` | No | No | Language hint passed to `plan_call`. Only provide when explicitly known. | `calle call plan --to-phone +15551234567 --goal "Confirm" --language English` | | `--region` | Text | None | `call plan`, `call start` | No | No | Region hint passed to `plan_call`. Only provide when explicitly known. | `calle call plan --to-phone +15551234567 --goal "Confirm" --region US` | | `--timezone` | IANA timezone | System timezone | `call plan`, `call start`, `call run`, `call recover`, `call status` | No | No | Adds planning timezone metadata for planning commands and localizes returned call timestamps for run/status commands. | `calle call status --run-id run_123 --timezone Asia/Shanghai` | +| `--redact-confirm-token` | Boolean | `false`; `CALLE_REDACT_CONFIRM_TOKEN=1` | `call plan` | No | No | Hide `confirm_token` in `call plan` stdout, set `has_confirm_token`, and store `plan_id` + `confirm_token` in the private recovery cache (`0600`). Default remains printed so skill `call plan` → `call run` keeps working. | `calle call plan --to-phone +15551234567 --goal "Confirm" --redact-confirm-token` | | `--plan-id` | Text | None | `call run` | Yes | No | Planned call ID returned by `plan_call`. Preserve exactly. | `calle call run --plan-id plan_123 --confirm-token token_123` | -| `--confirm-token` | Text | None | `call run` | Yes | No | Execution confirmation token returned by `plan_call`. Preserve exactly. | `calle call run --plan-id plan_123 --confirm-token token_123` | +| `--confirm-token` | Text | None | `call run` | Yes unless cached | No | Execution confirmation token returned by `plan_call`. Omit only when `call plan --redact-confirm-token` stored that `plan_id`. | `calle call run --plan-id plan_123 --confirm-token token_123` | | `--recovery-id` | Opaque text | None | `call recover` | Yes | No | Private-cache lookup ID returned when `run_call` has an uncertain outcome. Use only with the returned recovery command. | `calle call recover --recovery-id ` | | `--run-id` | Text | None | `call status` | Yes | No | Call run ID returned by `run_call` or `call start`. | `calle call status --run-id run_123` | | `--cursor` | Text | None | `call status` | No | No | Pagination cursor for `get_call_run` activity entries. | `calle call status --run-id run_123 --cursor cursor_123` | | `--limit` | Positive integer | None | `call status` | No | No | Maximum number of activity entries to request. | `calle call status --run-id run_123 --limit 20` | +`CALLE_REDACT_CONFIRM_TOKEN=1` is the environment equivalent of +`--redact-confirm-token` on `call plan`, following the same override order as +`CALLE_TIMEZONE` for `--timezone`: an explicit flag wins. + ## Telemetry Options The CLI sends best-effort usage telemetry for setup, auth, and MCP readiness diff --git a/packages/cli/lib/cache.js b/packages/cli/lib/cache.js index 97d1404..82ff9e9 100644 --- a/packages/cli/lib/cache.js +++ b/packages/cli/lib/cache.js @@ -3,6 +3,7 @@ import fs from "node:fs"; import path from "node:path"; import { + parseIsoDate, readJson, serverHash, writePrivateJson, @@ -71,6 +72,50 @@ export function removeCallRecovery(config, recoveryId) { } } +function planConfirmCacheId(planId) { + return crypto.createHash("sha256").update(String(planId), "utf8").digest("base64url"); +} + +export function writePlanConfirm(config, { planId, confirmToken, expiresAt = null }) { + if (typeof planId !== "string" || !planId.trim() || typeof confirmToken !== "string" || !confirmToken.trim()) { + throw new TypeError("Invalid plan confirmation cache record"); + } + writePrivateJson(callRecoveryCachePath(config.cacheRoot, config.serverUrl, planConfirmCacheId(planId.trim())), { + schema_version: 1, + created_at: new Date().toISOString(), + plan_id: planId.trim(), + confirm_token: confirmToken.trim(), + timezone: null, + expires_at: typeof expiresAt === "string" && expiresAt.trim() ? expiresAt.trim() : null, + }); +} + +export function readPlanConfirm(config, planId) { + if (typeof planId !== "string" || !planId.trim()) { + return null; + } + const record = readJson(callRecoveryCachePath(config.cacheRoot, config.serverUrl, planConfirmCacheId(planId.trim()))); + if ( + record?.schema_version !== 1 + || record.plan_id !== planId.trim() + || typeof record.confirm_token !== "string" + || !record.confirm_token + ) { + return null; + } + if (record.expires_at !== null && record.expires_at !== undefined && record.expires_at !== "") { + const expiresAt = parseIsoDate(record.expires_at); + if (!expiresAt || Date.now() >= expiresAt.getTime()) { + return null; + } + } + return { + planId: record.plan_id, + confirmToken: record.confirm_token, + expiresAt: typeof record.expires_at === "string" && record.expires_at ? record.expires_at : null, + }; +} + export function removeCallRecoveries(config) { const recoveryDir = callRecoveryCacheDir(config.cacheRoot, config.serverUrl); const existed = fs.existsSync(recoveryDir); diff --git a/packages/cli/lib/cli.js b/packages/cli/lib/cli.js index 10bfce7..b938685 100644 --- a/packages/cli/lib/cli.js +++ b/packages/cli/lib/cli.js @@ -5,6 +5,7 @@ import { readCallRecovery, readJson, readPendingLogin, + readPlanConfirm, removeCallRecoveries, removeCallRecovery, removeFile, @@ -12,6 +13,7 @@ import { tokenCachePath, tokenIsUsable, writeCallRecovery, + writePlanConfirm, } from "./cache.js"; import { DEFAULT_BASE_URL, @@ -157,6 +159,7 @@ const COMMAND_GROUPS = { " --language Optional language hint", " --region Optional region hint", " --timezone Optional planning timezone metadata", + " --redact-confirm-token Hide confirm_token and store it in the private cache", ], examples: [ `calle call plan --to-phone +15551234567 --goal "Confirm the appointment"`, @@ -181,7 +184,7 @@ const COMMAND_GROUPS = { usage: "calle call run --plan-id --confirm-token [options]", options: [ " --plan-id Required; plan ID returned by plan_call", - " --confirm-token Required; confirmation token returned by plan_call", + " --confirm-token Required unless a redacted plan is in the private cache", " --timezone Local timezone for returned call timestamps", ], examples: ["calle call run --plan-id plan_123 --confirm-token token_123"], @@ -250,7 +253,7 @@ const COMMAND_OPTION_NAMES = { "mcp config": new Set(), "mcp tools": new Set(), "mcp call": new Set(["args-json", "timezone"]), - "call plan": new Set(["to-phone", "goal", "language", "region", "timezone"]), + "call plan": new Set(["to-phone", "goal", "language", "region", "timezone", "redact-confirm-token"]), "call start": new Set(["to-phone", "goal", "language", "region", "timezone"]), "call run": new Set(["plan-id", "confirm-token", "timezone"]), "call recover": new Set(["recovery-id", "timezone"]), @@ -383,6 +386,7 @@ function parseOptions(argv) { "telemetry", "json", "help", + "redact-confirm-token", ]); for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; @@ -491,6 +495,21 @@ function resolvePlanTimezone(options, env = process.env) { return normalizeIanaTimezone(osTimezone()); } +function envFlagEnabled(env, key) { + const value = optionalEnvString(env, key); + if (!value) { + return false; + } + return ["1", "true", "yes", "on", "enabled"].includes(value.toLowerCase()); +} + +function resolveRedactConfirmToken(options, env = process.env) { + if (options.redactConfirmToken !== undefined) { + return Boolean(firstOptionValue(options.redactConfirmToken)); + } + return envFlagEnabled(env, "CALLE_REDACT_CONFIRM_TOKEN"); +} + function timezoneOffsetMinutes(timezone, instant = new Date()) { try { const parts = new Intl.DateTimeFormat("en-US", { @@ -971,6 +990,87 @@ function mcpSuccessPayload({ config, toolName = null, result, method = null }) { }; } +function hasConfirmTokenValue(value) { + return typeof value === "string" && value.trim().length > 0; +} + +function extractPlanConfirm(result) { + let planId = null; + let confirmToken = null; + let expiresAt = null; + const structured = recordObject(structuredPayload(result)) || {}; + if (typeof structured.plan_id === "string" && structured.plan_id.trim()) { + planId = structured.plan_id.trim(); + } + if (hasConfirmTokenValue(structured.confirm_token)) { + confirmToken = structured.confirm_token.trim(); + } + if (typeof structured.confirm_expires_at === "string" && structured.confirm_expires_at.trim()) { + expiresAt = structured.confirm_expires_at.trim(); + } + if (Array.isArray(result?.content)) { + for (const item of result.content) { + if (typeof item?.text !== "string") { + continue; + } + try { + const parsed = JSON.parse(item.text); + if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { + continue; + } + if (!planId && typeof parsed.plan_id === "string" && parsed.plan_id.trim()) { + planId = parsed.plan_id.trim(); + } + if (!confirmToken && hasConfirmTokenValue(parsed.confirm_token)) { + confirmToken = parsed.confirm_token.trim(); + } + if (!expiresAt && typeof parsed.confirm_expires_at === "string" && parsed.confirm_expires_at.trim()) { + expiresAt = parsed.confirm_expires_at.trim(); + } + } catch { + // Content text is not JSON; structured fields already collected. + } + } + } + return { planId, confirmToken, expiresAt }; +} + +function redactPlanCallResult(result, token) { + const cloned = result && typeof result === "object" ? structuredClone(result) : result; + if (!cloned || typeof cloned !== "object") { + return cloned; + } + const structured = recordObject(cloned.structuredContent) || recordObject(cloned.structured_content); + if (structured) { + structured.has_confirm_token = Boolean(token); + delete structured.confirm_token; + } + if (Array.isArray(cloned.content)) { + cloned.content = cloned.content.map((item) => { + if (!item || typeof item.text !== "string") { + return item; + } + let text = item.text; + try { + const parsed = JSON.parse(text); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + const nestedToken = hasConfirmTokenValue(parsed.confirm_token) ? parsed.confirm_token.trim() : token; + parsed.has_confirm_token = Boolean(nestedToken || token); + delete parsed.confirm_token; + text = JSON.stringify(parsed); + } + } catch { + // Fall through to string redaction when content is not JSON. + } + if (token && text.includes(token)) { + text = text.split(token).join(""); + } + return { ...item, text }; + }); + } + return cloned; +} + function buildPlanArguments(options) { const toPhones = optionValues(options.toPhone) .map((value) => String(value).trim()) @@ -1012,11 +1112,23 @@ function buildPlanRequestMeta(options, env = process.env) { return meta; } -function buildRunArguments(options) { - return { - plan_id: requireStringOption(options, "planId", "--plan-id"), - confirm_token: requireStringOption(options, "confirmToken", "--confirm-token"), - }; +function buildRunArguments(options, config) { + const planId = requireStringOption(options, "planId", "--plan-id"); + const explicitToken = optionalStringOption(options, "confirmToken"); + if (explicitToken) { + return { plan_id: planId, confirm_token: explicitToken, confirm_token_source: null }; + } + const cached = readPlanConfirm(config, planId); + if (cached?.confirmToken) { + return { + plan_id: planId, + confirm_token: cached.confirmToken, + confirm_token_source: "private_cache", + }; + } + throw new InvalidArgumentsError( + "Missing required --confirm-token. After `calle call plan --redact-confirm-token`, `call run --plan-id` reads the token from the private cache." + ); } function structuredPayload(result) { @@ -1291,6 +1403,7 @@ async function writeRunCallSuccess({ runId, statusTimezone, includeRunResult, + confirmTokenSource = null, }) { const { statusResult, statusError } = await fetchCallStatusBestEffort({ config, deps, runId }); if (statusResult) { @@ -1309,6 +1422,7 @@ async function writeRunCallSuccess({ status_query_succeeded: statusError === null, status_result: statusResult, ...(statusError ? { status_error: statusError } : {}), + ...(confirmTokenSource ? { confirm_token_source: confirmTokenSource } : {}), ...callStatusCommand(config, runId, statusTimezone), }); } @@ -1378,6 +1492,23 @@ async function handleCallCommand({ command, positional, options, config, deps, s callStarted: false, retrySafe: true, }); + const env = deps.env || process.env; + if (resolveRedactConfirmToken(options, env)) { + const extracted = extractPlanConfirm(result); + if (extracted.planId && extracted.confirmToken) { + writePlanConfirm(config, { + planId: extracted.planId, + confirmToken: extracted.confirmToken, + expiresAt: extracted.expiresAt, + }); + } + writeJson(stdout, mcpSuccessPayload({ + config, + toolName, + result: redactPlanCallResult(result, extracted.confirmToken), + })); + return 0; + } writeJson(stdout, mcpSuccessPayload({ config, toolName, result })); return 0; } @@ -1444,7 +1575,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s if (command === "run") { const statusTimezone = resolvePlanTimezone(options, deps.env || process.env); - const runArguments = buildRunArguments(options); + const runArguments = buildRunArguments(options, config); const { runResult, runId } = await runPlannedCall({ config, deps, @@ -1461,6 +1592,7 @@ async function handleCallCommand({ command, positional, options, config, deps, s runId, statusTimezone, includeRunResult: true, + confirmTokenSource: runArguments.confirm_token_source, }); return 0; } diff --git a/packages/cli/test/cli.test.js b/packages/cli/test/cli.test.js index 06f8401..bfb56ef 100644 --- a/packages/cli/test/cli.test.js +++ b/packages/cli/test/cli.test.js @@ -8,6 +8,7 @@ import { POST_AUTH_HELP_MESSAGE, preAuthHelpMessage, runCli } from "../lib/cli.j import { callRecoveryCachePath, pendingCachePath, + readPlanConfirm, tokenCachePath, writePrivateJson, } from "../lib/cache.js"; @@ -1275,6 +1276,235 @@ test("call plan maps flags to plan_call arguments", async () => { assert.equal(JSON.parse(result.stdout).tool_name, "plan_call"); }); +function planConfirmFetchImpl({ + structuredContent = { plan_id: "plan-1", confirm_token: "confirm-SECRET-1" }, + content = [ + { type: "text", text: '{"plan_id":"plan-1","confirm_token":"confirm-SECRET-1"}' }, + { type: "text", text: "token confirm-SECRET-1 in plain text" }, + ], +} = {}) { + return async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse({ jsonrpc: "2.0", id: payload.id, result: {} }); + } + if (payload.method === "notifications/initialized") { + return jsonRpcResponse({}); + } + if (payload.method === "tools/call") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { structuredContent, content }, + }); + } + throw new Error(`unexpected method: ${payload.method}`); + }; +} + +test("call plan prints confirm_token by default", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-plan-default-token"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + + const result = await run( + [ + "call", + "plan", + "--to-phone", + "+15551234567", + "--goal", + "Confirm appointment", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl: planConfirmFetchImpl() } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 0); + assert.equal(payload.result.structuredContent.confirm_token, "confirm-SECRET-1"); + assert.equal(payload.result.structuredContent.has_confirm_token, undefined); + assert.equal(readPlanConfirm({ cacheRoot, serverUrl }, "plan-1"), null); +}); + +test("call plan --redact-confirm-token hides JSON and plain-text tokens and writes the cache", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-plan-redact"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + + const result = await run( + [ + "call", + "plan", + "--to-phone", + "+15551234567", + "--goal", + "Confirm appointment", + "--redact-confirm-token", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl: planConfirmFetchImpl() } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 0); + assert.equal(payload.result.structuredContent.plan_id, "plan-1"); + assert.equal(payload.result.structuredContent.has_confirm_token, true); + assert.equal(payload.result.structuredContent.confirm_token, undefined); + assert.doesNotMatch(result.stdout, /confirm-SECRET-1/); + const redactedJson = JSON.parse(payload.result.content[0].text); + assert.equal(redactedJson.has_confirm_token, true); + assert.equal(redactedJson.confirm_token, undefined); + assert.doesNotMatch(payload.result.content[1].text, /confirm-SECRET-1/); + assert.deepEqual(readPlanConfirm({ cacheRoot, serverUrl }, "plan-1"), { + planId: "plan-1", + confirmToken: "confirm-SECRET-1", + expiresAt: null, + }); +}); + +test("call plan honours CALLE_REDACT_CONFIRM_TOKEN=1", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-plan-redact-env"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + + const result = await run( + [ + "call", + "plan", + "--to-phone", + "+15551234567", + "--goal", + "Confirm appointment", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { + env: { CALLE_REDACT_CONFIRM_TOKEN: "1" }, + fetchImpl: planConfirmFetchImpl(), + } + ); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 0); + assert.equal(payload.result.structuredContent.confirm_token, undefined); + assert.equal(payload.result.structuredContent.has_confirm_token, true); + assert.equal(readPlanConfirm({ cacheRoot, serverUrl }, "plan-1")?.confirmToken, "confirm-SECRET-1"); +}); + +test("call run reads a redacted confirm_token from the private cache", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-run-cache"); + const serverUrl = "https://mcp.example/mcp/openagent_oauth"; + writeToken(cacheRoot, serverUrl); + const toolCalls = []; + const fetchImpl = async (_url, init) => { + const payload = JSON.parse(init.body); + if (payload.method === "initialize") { + return jsonRpcResponse({ jsonrpc: "2.0", id: payload.id, result: {} }); + } + if (payload.method === "notifications/initialized") { + return jsonRpcResponse({}); + } + if (payload.method === "tools/call") { + toolCalls.push(payload.params); + if (payload.params.name === "plan_call") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { + structuredContent: { plan_id: "plan-1", confirm_token: "confirm-SECRET-1" }, + content: [{ type: "text", text: '{"plan_id":"plan-1","confirm_token":"confirm-SECRET-1"}' }], + }, + }); + } + if (payload.params.name === "run_call") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { structuredContent: { run_id: "run-1", status: "STARTED" } }, + }); + } + if (payload.params.name === "get_call_run") { + return jsonRpcResponse({ + jsonrpc: "2.0", + id: payload.id, + result: { structuredContent: { run_id: "run-1", status: "IN_PROGRESS" } }, + }); + } + } + throw new Error(`unexpected method: ${payload.method}`); + }; + + const planned = await run( + [ + "call", + "plan", + "--to-phone", + "+15551234567", + "--goal", + "Confirm appointment", + "--redact-confirm-token", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl } + ); + assert.equal(planned.code, 0); + assert.doesNotMatch(planned.stdout, /confirm-SECRET-1/); + + const ran = await run( + [ + "call", + "run", + "--plan-id", + "plan-1", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ], + { fetchImpl } + ); + const payload = JSON.parse(ran.stdout); + + assert.equal(ran.code, 0); + assert.equal(payload.confirm_token_source, "private_cache"); + assert.deepEqual(toolCalls.find((call) => call.name === "run_call")?.arguments, { + plan_id: "plan-1", + confirm_token: "confirm-SECRET-1", + }); +}); + +test("call run without --confirm-token errors when the private cache is empty", async () => { + const cacheRoot = makeTempRoot("calle-cli-call-run-missing-cache"); + const result = await run([ + "call", + "run", + "--plan-id", + "plan-1", + "--base-url", + "https://mcp.example", + "--cache-root", + cacheRoot, + ]); + const payload = JSON.parse(result.stdout); + + assert.equal(result.code, 2); + assert.equal(payload.error.code, "invalid_arguments"); + assert.match(payload.error.message, /^Missing required --confirm-token/); + assert.match(payload.error.message, /--redact-confirm-token/); +}); + test("call plan injects timezone meta from CALLE_TIMEZONE", async () => { const cacheRoot = makeTempRoot("calle-cli-call-plan-env-timezone"); const serverUrl = "https://mcp.example/mcp/openagent_oauth";