fix(status): sanitize pairing recovery details

This commit is contained in:
Ayaan Zaidi
2026-04-20 12:05:22 +05:30
parent 98a0b22e8e
commit 66e1c3982d
4 changed files with 49 additions and 20 deletions

View File

@@ -1,9 +1,11 @@
import { withProgress } from "../cli/progress.js";
import {
normalizePairingConnectRequestId,
readPairingConnectErrorDetails,
type ConnectPairingRequiredReason,
} from "../gateway/protocol/connect-error-details.js";
import { type RuntimeEnv } from "../runtime.js";
import { sanitizeTerminalText } from "../terminal/safe-text.js";
import { runStatusJsonCommand } from "./status-json-command.ts";
import { buildStatusOverviewSurfaceFromScan } from "./status-overview-surface.ts";
import {
@@ -72,22 +74,13 @@ export function resolvePairingRecoveryContext(params: {
const structured = readPairingConnectErrorDetails(params.details);
if (structured) {
return {
requestId: structured.requestId ?? null,
requestId: normalizePairingConnectRequestId(structured.requestId) ?? null,
reason: structured.reason ?? null,
remediationHint: structured.remediationHint ?? null,
remediationHint: structured.remediationHint
? sanitizeTerminalText(structured.remediationHint)
: null,
};
}
const sanitizeRequestId = (value: string): string | null => {
const trimmed = value.trim();
if (!trimmed) {
return null;
}
// Keep CLI guidance injection-safe: allow only compact id characters.
if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/.test(trimmed)) {
return null;
}
return trimmed;
};
const source = [params.error, params.closeReason]
.filter((part) => typeof part === "string" && part.trim().length > 0)
.join(" ");
@@ -96,7 +89,9 @@ export function resolvePairingRecoveryContext(params: {
}
const requestIdMatch = source.match(/requestId:\s*([^\s)]+)/i);
const requestId =
requestIdMatch && requestIdMatch[1] ? sanitizeRequestId(requestIdMatch[1]) : null;
requestIdMatch && requestIdMatch[1]
? (normalizePairingConnectRequestId(requestIdMatch[1]) ?? null)
: null;
return { requestId: requestId || null, reason: null, remediationHint: null };
}

View File

@@ -1306,6 +1306,20 @@ describe("statusCommand", () => {
reason: "scope-upgrade",
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
});
expect(
resolvePairingRecoveryContext({
details: {
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-structured-789;rm -rf /",
remediationHint: "\u001b[31mReview\nfirst\u001b[0m",
},
}),
).toEqual({
requestId: null,
reason: "scope-upgrade",
remediationHint: "Review\\nfirst",
});
mocks.loadConfig.mockReturnValue({
session: {},

View File

@@ -5,6 +5,7 @@ import {
buildPairingConnectErrorMessage,
ConnectPairingRequiredReasons,
describePairingConnectRequirement,
normalizePairingConnectRequestId,
readConnectErrorDetailCode,
readConnectErrorRecoveryAdvice,
readPairingConnectErrorDetails,
@@ -96,4 +97,20 @@ describe("pairing connect details", () => {
"pairing required: device is asking for a higher role than currently approved (requestId: req-789)",
);
});
it("drops request ids that do not match the allowlist", () => {
expect(normalizePairingConnectRequestId("req-123")).toBe("req-123");
expect(normalizePairingConnectRequestId("req-123;rm -rf /")).toBeUndefined();
expect(
readPairingConnectErrorDetails({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
requestId: "req-123;rm -rf /",
}),
).toEqual({
code: "PAIRING_REQUIRED",
reason: "scope-upgrade",
remediationHint: "Review the requested scopes, then approve the pending upgrade.",
});
});
});

View File

@@ -75,6 +75,7 @@ const CONNECT_PAIRING_REQUIRED_REASON_VALUES: ReadonlySet<ConnectPairingRequired
"scope-upgrade",
"metadata-upgrade",
]);
const PAIRING_CONNECT_REQUEST_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
const PAIRING_CONNECT_REASON_METADATA: Readonly<
Record<
@@ -203,6 +204,11 @@ function normalizePairingConnectReason(value: unknown): ConnectPairingRequiredRe
: undefined;
}
export function normalizePairingConnectRequestId(value: unknown): string | undefined {
const normalized = normalizeOptionalString(value);
return normalized && PAIRING_CONNECT_REQUEST_ID_PATTERN.test(normalized) ? normalized : undefined;
}
export function describePairingConnectRequirement(
reason: ConnectPairingRequiredReason | undefined,
): string {
@@ -240,7 +246,7 @@ export function buildPairingConnectErrorDetails(params: {
requestId?: string;
remediationHint?: string;
}): PairingConnectErrorDetails {
const requestId = normalizeOptionalString(params.requestId) ?? undefined;
const requestId = normalizePairingConnectRequestId(params.requestId);
const remediationHint =
normalizeOptionalString(params.remediationHint) ??
buildPairingConnectRemediationHint(params.reason);
@@ -256,7 +262,7 @@ export function buildPairingConnectCloseReason(params: {
reason: ConnectPairingRequiredReason | undefined;
requestId?: string;
}): string {
const requestId = normalizeOptionalString(params.requestId) ?? undefined;
const requestId = normalizePairingConnectRequestId(params.requestId);
const message = buildPairingConnectErrorMessage(params.reason);
return requestId ? `${message} (requestId: ${requestId})` : message;
}
@@ -267,16 +273,13 @@ export function readPairingConnectErrorDetails(
if (readConnectErrorDetailCode(details) !== ConnectErrorDetailCodes.PAIRING_REQUIRED) {
return null;
}
if (!details || typeof details !== "object" || Array.isArray(details)) {
return { code: ConnectErrorDetailCodes.PAIRING_REQUIRED };
}
const raw = details as {
reason?: unknown;
requestId?: unknown;
remediationHint?: unknown;
};
const reason = normalizePairingConnectReason(raw.reason);
const requestId = normalizeOptionalString(raw.requestId) ?? undefined;
const requestId = normalizePairingConnectRequestId(raw.requestId);
const remediationHint =
normalizeOptionalString(raw.remediationHint) ?? buildPairingConnectRemediationHint(reason);
return {