diff --git a/src/browser/features/Settings/Sections/BackupSection.tsx b/src/browser/features/Settings/Sections/BackupSection.tsx
index 2f6a40dcc5..9a11603a80 100644
--- a/src/browser/features/Settings/Sections/BackupSection.tsx
+++ b/src/browser/features/Settings/Sections/BackupSection.tsx
@@ -359,15 +359,23 @@ export function BackupSection() {
setStatusMessage(null);
setPreview(null);
setOverrideSecretScan(false);
+ // Cleared before the await, so a rejection (transport failure, not a scan result)
+ // cannot leave a previous scan's override rendering beside an unrelated error.
+ setSecretScanBlocked(false);
+ setSecretScanApproval(null);
try {
const result = await api.backup.preview(savedDraft);
if (!result.success) {
setActionError(getOperationErrorMessage(result.error));
+ // The scan state must describe this failure, not a previous push's: a stale
+ // digest would keep rendering an override the backend now rejects.
+ const blocked = result.error.code === "SECRET_DETECTED";
+ setSecretScanBlocked(blocked);
+ setSecretScanApproval(blocked ? (result.error.secretApproval ?? null) : null);
return;
}
setPreview(result.data);
- setSecretScanBlocked(false);
const nextApprovals = result.data.commandApprovals;
// An approval only covers the exact command text the user read, so a changed list
// has to be read again.
@@ -600,10 +608,10 @@ export function BackupSection() {
))}
- Provider key files and dedicated secret files have no export path. MCP commands and URLs
- are included verbatim; credential-like URL components require review, while literal MCP
- header values are redacted. Inside skills and memory, only documentation is published
- automatically; any other file waits for you to review it.
+ Provider key files and dedicated secret files have no export path. Env-style values in MCP
+ commands, URLs carrying credentials, and literal MCP header values are redacted;
+ publishing a command still requires review. Inside skills and memory, only documentation
+ is published automatically; any other file waits for you to review it.
@@ -713,7 +721,9 @@ export function BackupSection() {
)}
- {secretScanBlocked ? (
+ {/* A credential-format block carries no approval digest and cannot be overridden,
+ so a dead override control must not suggest otherwise. */}
+ {secretScanBlocked && secretScanApproval !== null ? (
{
"ssh+git:user:hunter2@",
"https://example.com/repo.git?access_token=hunter2",
"https://example.com/repo.git?passphrase=hunter2",
+ "https://example.com/repo.git?Ocp-Apim-Subscription-Key=hunter2",
"https://example.com/repo.git#access_token=hunter2",
]) {
expect(SettingsBackupSchema.safeParse({ ...base, repoUrl }).success).toBe(false);
@@ -149,6 +150,9 @@ describe("AppConfigOnDiskSchema", () => {
for (const repoUrl of [
"https://github.com/me/dotfiles.git",
"https://github.com/me/dotfiles.git?client_id=mux",
+ // A descriptive option that happens to end in a credential word is not a
+ // provider-qualified signed-URL parameter.
+ "https://github.com/me/dotfiles.git?verify_signature=false",
"https://github.com/me/dotfiles.git?code=review&key=branch&session=docs",
"https://github.com/me/dotfiles.git#section=backup",
"ssh://git@example.com/repo.git",
diff --git a/src/common/config/schemas/settingsBackup.ts b/src/common/config/schemas/settingsBackup.ts
index 9f3cadff42..5d3cdff644 100644
--- a/src/common/config/schemas/settingsBackup.ts
+++ b/src/common/config/schemas/settingsBackup.ts
@@ -79,9 +79,14 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"accesskeyid",
"accesstoken",
"apikey",
+ "apisecret",
+ "apitoken",
+ "appkey",
"appsecret",
+ "apptoken",
"auth",
"authcode",
+ "authkey",
"authorization",
"authtoken",
"awsaccesskeyid",
@@ -90,6 +95,7 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"bearertoken",
"clientkey",
"clientsecret",
+ "clienttoken",
"consumersecret",
"credential",
"credentials",
@@ -100,25 +106,46 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"passwd",
"password",
"privatekey",
+ "privatetoken",
"pwd",
"refreshtoken",
"secret",
"secretaccesskey",
"secretkey",
+ "secrettoken",
+ "securitytoken",
"sessionid",
+ "sessiontoken",
+ "subscriptionkey",
+ "ocpapimsubscriptionkey",
"signature",
"token",
"xamzcredential",
"xamzsignature",
]);
+/**
+ * Signed-URL families qualify the credential word with a header-style provider
+ * prefix (`X-Goog-Signature`, `X-Amz-Credential`, `x-oss-security-token`), so an
+ * `x`-led name ending in one of these unambiguous words matches without enumerating
+ * providers. Descriptive options that merely end in the word
+ * (`verify_signature=false`) carry no provider marker and stay accepted.
+ */
+const PROVIDER_CREDENTIAL_NAME =
+ /^x[a-z0-9]*(?:accesskeyid|credential|secretaccesskey|securitytoken|signature)$/;
+
function parametersContainCredential(
parameters: URLSearchParams,
names: ReadonlySet
): boolean {
for (const [name, value] of parameters) {
const normalizedName = name.toLowerCase().replace(/[^a-z0-9]/g, "");
- if (value !== "" && names.has(normalizedName)) return true;
+ if (value === "") continue;
+ if (names.has(normalizedName)) return true;
+ // Header-style spellings prefix the same names with `x` (`x-api-key`,
+ // `X-Auth-Token`), so one stripped leading `x` matches the whole class.
+ if (normalizedName.startsWith("x") && names.has(normalizedName.slice(1))) return true;
+ if (PROVIDER_CREDENTIAL_NAME.test(normalizedName)) return true;
}
return false;
}
diff --git a/src/node/services/backup/backupService.integration.test.ts b/src/node/services/backup/backupService.integration.test.ts
index ee507d0592..035d5b169a 100644
--- a/src/node/services/backup/backupService.integration.test.ts
+++ b/src/node/services/backup/backupService.integration.test.ts
@@ -156,7 +156,7 @@ describe("BackupService against a real repository", () => {
);
});
- it("blocks a push when a backed-up file contains a token, and proceeds once allowed", async () => {
+ it("blocks a push outright when a backed-up file contains a credential token", async () => {
await writeFixtureFile(
muxRoot,
"AGENTS.md",
@@ -165,34 +165,31 @@ describe("BackupService against a real repository", () => {
const blocked = await service.push(settings);
expect(blocked.success).toBe(false);
- if (blocked.success) throw new Error("Expected the secret scan to block the push");
+ if (blocked.success) throw new Error("Expected the credential backstop to block the push");
expect(blocked.error.code).toBe("SECRET_DETECTED");
expect(blocked.error.files).toContain("AGENTS.md");
+ // No approval digest: a credential-format match has no user override.
+ expect(blocked.error.secretApproval ?? null).toBeNull();
expect(await runGit(["--git-dir", originPath, "rev-list", "--count", "--all"])).toBe("0");
- const allowed = await service.push(settings, {
- approvedSecretDigest: blocked.error.secretApproval ?? undefined,
- });
- expect(allowed.success).toBe(true);
+ const stillBlocked = await service.push(settings, { approvedSecretDigest: "any-digest" });
+ expect(stillBlocked.success).toBe(false);
+ expect(await runGit(["--git-dir", originPath, "rev-list", "--count", "--all"])).toBe("0");
});
- it("gates a low-entropy MCP URL credential until the exact payload is approved", async () => {
+ it("redacts a low-entropy MCP URL credential instead of gating the push", async () => {
const url = "https://user:hunter2@example.com/mcp?api_key=abc123";
await writeFixtureFile(muxRoot, "mcp.jsonc", JSON.stringify({ servers: { private: { url } } }));
- const blocked = await service.push(settings);
- expect(blocked.success).toBe(false);
- if (blocked.success) throw new Error("Expected the URL credential gate to block the push");
- expect(blocked.error.code).toBe("SECRET_DETECTED");
- expect(blocked.error.files).toEqual(["mcp.jsonc"]);
- expect(await runGit(["--git-dir", originPath, "rev-list", "--count", "--all"])).toBe("0");
+ const pushed = await service.push(settings);
+ expect(pushed.success).toBe(true);
+ if (!pushed.success) throw new Error("Expected the redacted payload to push cleanly");
+ expect(pushed.data.redactions).toEqual(["servers.private.url"]);
- const allowed = await service.push(settings, {
- approvedSecretDigest: blocked.error.secretApproval ?? undefined,
- });
- expect(allowed.success).toBe(true);
const clone = await cloneOrigin("url-credential-verify");
- expect(await fs.readFile(path.join(clone, "mux/mcp.jsonc"), "utf-8")).toContain(url);
+ const published = await fs.readFile(path.join(clone, "mux/mcp.jsonc"), "utf-8");
+ expect(published).not.toContain("hunter2");
+ expect(published).toContain(REDACTED_BACKUP_VALUE);
});
it("requires exact-payload approval before publishing an MCP command", async () => {
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index b8609aee8e..6a7104242c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -9,10 +9,12 @@ import { execFileAsync } from "@/node/utils/disposableExec";
import {
BACKUP_SCHEMA_VERSION,
BackupCommandApprovalRequiredError,
+ BackupCredentialDetectedError,
assertBackupCommandsApproved,
MAX_BACKUP_DIRECTORY_COUNT,
MAX_BACKUP_FILE_BYTES,
MAX_BACKUP_FILE_COUNT,
+ MAX_ANALYZED_COMMAND_LENGTH,
MAX_BACKUP_MCP_REDACTIONS,
MAX_BACKUP_MCP_REDACTION_PATH_SEGMENTS,
MAX_BACKUP_MCP_REDACTION_SEGMENTS,
@@ -134,6 +136,23 @@ function withPayloadFileText(
return { ...payload, files };
}
+// Ambient Bash startup hooks (BASH_ENV, exported BASH_FUNC_* functions) localize every
+// command, which would silently flip portability expectations on hosts whose
+// environment carries them.
+let ambientStartupHookEnv: Array<[string, string]> = [];
+beforeEach(() => {
+ ambientStartupHookEnv = [];
+ for (const [name, value] of Object.entries(process.env)) {
+ if ((name === "BASH_ENV" || name.startsWith("BASH_FUNC_")) && value !== undefined) {
+ ambientStartupHookEnv.push([name, value]);
+ delete process.env[name];
+ }
+ }
+});
+afterEach(() => {
+ for (const [name, value] of ambientStartupHookEnv) process.env[name] = value;
+});
+
describe("backup payload", () => {
let tempDir: string;
let muxRoot: string;
@@ -148,126 +167,4044 @@ describe("backup payload", () => {
await fs.rm(tempDir, { recursive: true, force: true });
});
- it("collects only explicitly allowed files and preferences", async () => {
- await writeFixtureFile(muxRoot, "AGENTS.md", "shared instructions\n");
- await writeFixtureFile(muxRoot, "AGENTS.local.md", "private instructions\n");
- await writeFixtureFile(muxRoot, "agents/reviewer.md", "reviewer\n");
- await writeFixtureFile(muxRoot, "agents/notes.txt", "not an agent\n");
- await writeFixtureFile(muxRoot, "agents/nested/hidden.md", "nested agent\n");
- await writeFixtureFile(muxRoot, "skills/review/SKILL.md", "skill\n");
- await writeFixtureFile(muxRoot, "skills/review/providers.jsonc", "{}\n");
- await writeFixtureFile(muxRoot, "memory/global/note.md", "memory\n");
- await writeFixtureFile(muxRoot, "memory/global/memory-meta.json", "{}\n");
- for (const secretFile of [
- "providers.jsonc",
- "secrets.json",
- "mcp-oauth.json",
- "server.lock",
- "serverAuthSessions.json",
+ it("collects only explicitly allowed files and preferences", async () => {
+ await writeFixtureFile(muxRoot, "AGENTS.md", "shared instructions\n");
+ await writeFixtureFile(muxRoot, "AGENTS.local.md", "private instructions\n");
+ await writeFixtureFile(muxRoot, "agents/reviewer.md", "reviewer\n");
+ await writeFixtureFile(muxRoot, "agents/notes.txt", "not an agent\n");
+ await writeFixtureFile(muxRoot, "agents/nested/hidden.md", "nested agent\n");
+ await writeFixtureFile(muxRoot, "skills/review/SKILL.md", "skill\n");
+ await writeFixtureFile(muxRoot, "skills/review/providers.jsonc", "{}\n");
+ await writeFixtureFile(muxRoot, "memory/global/note.md", "memory\n");
+ await writeFixtureFile(muxRoot, "memory/global/memory-meta.json", "{}\n");
+ for (const secretFile of [
+ "providers.jsonc",
+ "secrets.json",
+ "mcp-oauth.json",
+ "server.lock",
+ "serverAuthSessions.json",
+ ]) {
+ await writeFixtureFile(muxRoot, secretFile, "must not export\n");
+ }
+
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ exportedAt: "2026-07-30T00:00:00.000Z",
+ preferences: {
+ appearance: { theme: "dark", vimEnabled: true },
+ navigation: { launchBehavior: "dashboard", projectOrder: ["/private/project"] },
+ ai: {
+ globalDefaults: { agentId: "exec" },
+ projectDefaults: { "/private/project": { model: "secret/model" } },
+ autoCompactionThresholdByModel: { "openai/gpt": 75 },
+ },
+ workspaceCreation: { byProject: { "/private/project": { trunkBranch: "main" } } },
+ notifications: { notifyOnResponseByWorkspace: { workspace: true } },
+ review: {
+ includeUncommitted: true,
+ defaultBaseByProject: { "/private/project": "main" },
+ },
+ },
+ });
+
+ expect(payload.files.map((file) => file.path)).toEqual([
+ "AGENTS.md",
+ "agents/reviewer.md",
+ "memory/global/note.md",
+ "preferences.json",
+ "skills/review/SKILL.md",
+ ]);
+ expect(payload.manifest.files.map((file) => file.path)).toEqual(
+ payload.files.map((file) => file.path)
+ );
+ const preferences = JSON.parse(payloadFileText(payload, "preferences.json")) as Record<
+ string,
+ unknown
+ >;
+ expect(preferences).toEqual({
+ appearance: { theme: "dark", vimEnabled: true },
+ navigation: { launchBehavior: "dashboard" },
+ ai: {
+ globalDefaults: { agentId: "exec" },
+ autoCompactionThresholdByModel: { "openai/gpt": 75 },
+ },
+ review: { includeUncommitted: true },
+ });
+ });
+
+ it("redacts credential-bearing URLs and literal header values while keeping plain commands", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ `{
+ // Deploy token: commentsecret
+ "servers": {
+ "api": {
+ "url": "https://user:password@example.com/mcp?token=literal&clientSecret=camel2&X-Amz-Signature=deadbeefcafe&mode=fast",
+ "headers": {
+ "Authorization": "Bearer literal",
+ "Secret": { "secret": "MCP_SECRET" }
+ }
+ },
+ "plain": {
+ "url": "https://example.com/mcp?mode=fast"
+ },
+ "objectCommand": { "command": "npx object-mcp --root /workspace" },
+ "bareCommand": "bare-mcp --verbose"
+ }
+}
+`
+ );
+
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: {
+ api: { url: string; headers: Record };
+ plain: { url: string };
+ objectCommand: { command: string };
+ bareCommand: string;
+ };
+ };
+
+ expect(mcp.servers.api.headers.Authorization).toBe(REDACTED_BACKUP_VALUE);
+ expect(mcp.servers.api.headers.Secret).toEqual({ secret: "MCP_SECRET" });
+ expect(mcp.servers.api.url).toBe(REDACTED_BACKUP_VALUE);
+ expect(mcp.servers.plain.url).toBe("https://example.com/mcp?mode=fast");
+ expect(mcp.servers.objectCommand.command).toBe("npx object-mcp --root /workspace");
+ expect(mcp.servers.bareCommand).toBe("bare-mcp --verbose");
+ const text = payloadFileText(payload, "mcp.jsonc");
+ expect(text).not.toContain("commentsecret");
+ expect(text).not.toContain("user:password");
+ const destination = path.join(tempDir, "redacted-payload");
+ await writeBackupPayload(destination, payload);
+ expect((await readBackupPayload(destination)).redactions).toEqual(payload.redactions);
+ expect(payload.redactions).toEqual(["servers.api.url", "servers.api.headers.Authorization"]);
+ });
+
+ it("redacts inline env-style credentials in command strings into the manifest", async () => {
+ const token = "glsa_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_00000000";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: {
+ command: `GRAFANA_URL=https://grafana.example ORG_ID="1 2" GRAFANA_SERVICE_ACCOUNT_TOKEN=${token} mcp-grafana --transport stdio`,
+ },
+ bare: "FOO_TOKEN=hunter2 bare-mcp --verbose",
+ },
+ })
+ );
+
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const text = payloadFileText(payload, "mcp.jsonc");
+ expect(text).not.toContain(token);
+ expect(text).not.toContain("hunter2");
+ const mcp = jsonc.parse(text) as { servers: { grafana: { command: string }; bare: string } };
+ expect(mcp.servers.grafana.command).toBe(
+ `GRAFANA_URL=${REDACTED_BACKUP_VALUE} ORG_ID=${REDACTED_BACKUP_VALUE} GRAFANA_SERVICE_ACCOUNT_TOKEN=${REDACTED_BACKUP_VALUE} mcp-grafana --transport stdio`
+ );
+ expect(mcp.servers.bare).toBe(`FOO_TOKEN=${REDACTED_BACKUP_VALUE} bare-mcp --verbose`);
+ expect(payload.manifest.mcpRedactions).toEqual([
+ ["servers", "grafana", "command"],
+ ["servers", "bare"],
+ ]);
+
+ // The published checksum must verify against the redacted bytes as written.
+ const destination = path.join(tempDir, "command-redacted-payload");
+ await writeBackupPayload(destination, payload);
+ const written = await fs.readFile(path.join(destination, "mcp.jsonc"), "utf-8");
+ expect(written).not.toContain(token);
+ const entry = payload.manifest.files.find((file) => file.path === "mcp.jsonc");
+ expect(entry?.sha256).toBe(sha256Hex(written));
+ expect((await readBackupPayload(destination)).redactions).toEqual(payload.redactions);
+ });
+
+ async function expectCommandRedaction(command: string, expected: string): Promise {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { notes: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const text = payloadFileText(payload, "mcp.jsonc");
+ expect(text).not.toContain("hunter2");
+ const mcp = jsonc.parse(text) as { servers: { notes: { command: string } } };
+ expect(mcp.servers.notes.command).toBe(expected);
+ expect(payload.manifest.mcpRedactions).toEqual([["servers", "notes", "command"]]);
+ }
+
+ it("consumes a whole shell word per assignment and localizes unparseable commands", async () => {
+ const cases: Array<[string, string]> = [
+ // An escaped space extends the word, so the credential's second half is inside it.
+ ["TOKEN=abc\\ hunter2 notes-mcp", `TOKEN=${REDACTED_BACKUP_VALUE} notes-mcp`],
+ // Quoted segments concatenate into the same word.
+ [`TOKEN="a hunter2"'b hunter2'c notes-mcp`, `TOKEN=${REDACTED_BACKUP_VALUE} notes-mcp`],
+ // An unterminated quote leaves the value's extent unknowable.
+ ["TOKEN='abc hunter2 notes-mcp", REDACTED_BACKUP_VALUE],
+ // So does a trailing backslash.
+ ["TOKEN=hunter2\\", REDACTED_BACKUP_VALUE],
+ // Expansions splice one word across whitespace.
+ ["TOKEN=$(cat hunter2) notes-mcp", REDACTED_BACKUP_VALUE],
+ ["TOKEN=${X:-abc hunter2} notes-mcp", REDACTED_BACKUP_VALUE],
+ ];
+ for (const [command, expected] of cases) {
+ await expectCommandRedaction(command, expected);
+ }
+ });
+
+ it("recognizes assignments after shell operators and fails closed inside quotes", async () => {
+ const cases: Array<[string, string]> = [
+ // Control operators end the previous word without whitespace.
+ ["bootstrap;TOKEN=hunter2 mcp-server", `bootstrap;TOKEN=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["mcp-a&&TOKEN=hunter2 mcp-b", `mcp-a&&TOKEN=${REDACTED_BACKUP_VALUE} mcp-b`],
+ // A pipe moves bytes between stages, so the whole command goes machine-local.
+ ["mcp-a|TOKEN=hunter2 mcp-b", REDACTED_BACKUP_VALUE],
+ ["(TOKEN=hunter2 mcp-server)", `(TOKEN=${REDACTED_BACKUP_VALUE} mcp-server)`],
+ // An unquoted value ends at an operator, and the assignment after it still redacts.
+ [
+ "A=1;B=hunter2 mcp-server",
+ `A=${REDACTED_BACKUP_VALUE};B=${REDACTED_BACKUP_VALUE} mcp-server`,
+ ],
+ // Substitution around an assignment localizes the whole command.
+ ["mcp-a `TOKEN=hunter2 leak`", REDACTED_BACKUP_VALUE],
+ ["mcp-run ${X=hunter2}", REDACTED_BACKUP_VALUE],
+ // A quote-led assignment is a word to the shell, but eval-style consumers read it.
+ ['run-mcp "TOKEN=a hunter2"', REDACTED_BACKUP_VALUE],
+ ["eval 'TOKEN=hunter2 mcp'", REDACTED_BACKUP_VALUE],
+ // Quote removal can still hand env-style consumers an assignment.
+ ["env TOKEN\\=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["T\\OKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ['"TOKEN"=hunter2 mcp-server', REDACTED_BACKUP_VALUE],
+ // Process substitution is an expansion, wherever it appears.
+ ["TOKEN=<(printf hunter2) mcp-server", REDACTED_BACKUP_VALUE],
+ ["FOO=1 mcp-server <(printf hunter2)", REDACTED_BACKUP_VALUE],
+ // GNU env operand names are not limited to shell identifiers.
+ ["env TOKEN-NAME=hunter2 mcp-server", `env TOKEN-NAME=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["env TOKEN:NAME=hunter2 mcp-server", `env TOKEN:NAME=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["env =hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ // Braces stay inside the word, so the marker distributes through any expansion.
+ ["env {TOK,EN}=hunter2 mcp-server", `env {TOK,EN}=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["env TOK{A,B}=hunter2 mcp-server", `env TOK{A,B}=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["TOKEN=public{hunter2} mcp-server", `TOKEN=${REDACTED_BACKUP_VALUE} mcp-server`],
+ // Expansion braces in any unconsumed word can reassemble a credential.
+ [
+ "mcp-grafana --token ghp_12345678901234567{8..8}90123456789012345678",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // An option value can embed a whole assignment for the target program.
+ ["systemd-run --setenv=TOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["docker run --env=TOKEN=hunter2 mcp-image", REDACTED_BACKUP_VALUE],
+ // A short option's attached argument has no boundary before the assignment.
+ ["systemd-run -ETOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ // A plain flag value has no inner assignment and stays published.
+ [
+ "mcp-run --transport=stdio TOKEN=hunter2",
+ `mcp-run --transport=stdio TOKEN=${REDACTED_BACKUP_VALUE}`,
+ ],
+ // After an option terminator, even an option-looking word is an env operand,
+ // and the terminator itself may arrive through quote removal.
+ ["env -- --evil=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env - --evil=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ['env "--" --evil=hunter2 mcp-server', REDACTED_BACKUP_VALUE],
+ // Append assignments set an unset name and export the same way.
+ ["TOKEN+=hunter2 mcp-server", `TOKEN+=${REDACTED_BACKUP_VALUE} mcp-server`],
+ ["mcp-a;TOKEN+=hunter2 mcp-b", `mcp-a;TOKEN+=${REDACTED_BACKUP_VALUE} mcp-b`],
+ ["eval 'TOKEN+=hunter2 mcp'", REDACTED_BACKUP_VALUE],
+ // An array value leaves a bare assignment word behind, which fails closed.
+ ["TOKEN=(a hunter2) mcp-server", REDACTED_BACKUP_VALUE],
+ ["TOKEN=(hunter2) mcp-server", REDACTED_BACKUP_VALUE],
+ // ANSI-C and locale quoting hand env-style consumers their inner text.
+ ["env $'TOKEN=hunter2' mcp-server", REDACTED_BACKUP_VALUE],
+ ['env $"TOKEN=hunter2" mcp-server', REDACTED_BACKUP_VALUE],
+ // A quoted script string is re-parsed by its interpreter, whatever the grammar.
+ ["powershell -Command '$env:TOKEN=\"hunter2\"; mcp-server'", REDACTED_BACKUP_VALUE],
+ ["sh -c 'exec TOKEN=hunter2 mcp'", REDACTED_BACKUP_VALUE],
+ // A consumed assignment inside a larger script word must not exempt the rest.
+ ["sh -c 'A=1 $env:TOKEN=hunter2 mcp'", REDACTED_BACKUP_VALUE],
+ ["csh -c 'setenv TOKEN hunter2; mcp'", REDACTED_BACKUP_VALUE],
+ ["pwsh -c $env:TOKEN=hunter2;mcp-server", REDACTED_BACKUP_VALUE],
+ // GNU env re-splits a split-string value into assignments, under any unique
+ // long-option abbreviation.
+ ["env --split-string='TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
+ ["env --s=TOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env --split=TOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env -S'TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
+ ["env -STOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env -0STOKEN=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ // Expansion bodies can smuggle assignment bytes past every lexical check.
+ ["env TOKEN$(printf =hunter2) mcp-server", REDACTED_BACKUP_VALUE],
+ ["env TOKEN$[0]=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env $'TOKEN\\x3dhunter2' mcp-server", REDACTED_BACKUP_VALUE],
+ ["mcp-run ${X:-hunter2}", REDACTED_BACKUP_VALUE],
+ ];
+ for (const [command, expected] of cases) {
+ await expectCommandRedaction(command, expected);
+ }
+ });
+
+ it("restores an inline-redacted command from the local config and drops it elsewhere", async () => {
+ const command = "FOO_TOKEN=hunter2 notes-mcp --verbose";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { notes: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+
+ const restored = jsonc.parse(
+ (
+ await resolveRestoredContent(
+ muxRoot,
+ payloadFile(payload, "mcp.jsonc"),
+ payload.manifest.mcpRedactions
+ )
+ ).toString("utf-8")
+ ) as { servers: { notes: { command: string } } };
+ expect(restored.servers.notes.command).toBe(command);
+
+ // A machine without the local command must not gain one the backup cannot carry.
+ const otherRoot = path.join(tempDir, "other-root");
+ await fs.mkdir(otherRoot);
+ const elsewhere = jsonc.parse(
+ (
+ await resolveRestoredContent(
+ otherRoot,
+ payloadFile(payload, "mcp.jsonc"),
+ payload.manifest.mcpRedactions
+ )
+ ).toString("utf-8")
+ ) as { servers: Record };
+ expect(elsewhere.servers.notes).toBeUndefined();
+ });
+
+ it("restores a redacted URL from the local config and drops it elsewhere", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ remote: { url: "https://user:hunter2@example.com/mcp" },
+ mixed: { command: "npx notes-mcp", url: "https://mcp.example.com/mcp?api_key=hunter2" },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+
+ const restored = jsonc.parse(
+ (
+ await resolveRestoredContent(
+ muxRoot,
+ payloadFile(payload, "mcp.jsonc"),
+ payload.manifest.mcpRedactions
+ )
+ ).toString("utf-8")
+ ) as { servers: Record };
+ expect(restored.servers.remote.url).toBe("https://user:hunter2@example.com/mcp");
+ expect(restored.servers.mixed.url).toBe("https://mcp.example.com/mcp?api_key=hunter2");
+
+ // A machine without the local url must not keep the marker as a connectable endpoint:
+ // a url-only entry disappears, a mixed one falls back to its stdio command.
+ const otherRoot = path.join(tempDir, "other-url-root");
+ await fs.mkdir(otherRoot);
+ const elsewhere = jsonc.parse(
+ (
+ await resolveRestoredContent(
+ otherRoot,
+ payloadFile(payload, "mcp.jsonc"),
+ payload.manifest.mcpRedactions
+ )
+ ).toString("utf-8")
+ ) as { servers: Record };
+ expect(elsewhere.servers.remote).toBeUndefined();
+ expect(elsewhere.servers.mixed).toEqual({ command: "npx notes-mcp" });
+
+ // The stdio fallback the url removal exposes still needs the user to read the command.
+ const approvals = await collectMcpCommandApprovals(
+ otherRoot,
+ payload.files,
+ payload.manifest.mcpRedactions
+ );
+ expect(approvals.map((approval) => approval.command)).toEqual(["npx notes-mcp"]);
+ });
+
+ it("blocks the export outright when a credential pattern survives redaction", async () => {
+ const token = "glsa_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6_00000000";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ // As a plain argument rather than an env assignment, so redaction does not classify it.
+ JSON.stringify({ servers: { grafana: { command: `mcp-grafana --token ${token}` } } })
+ );
+
+ // reportSecrets covers only the reviewable scan; the credential backstop has no override.
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
+
+ // The local safety snapshot never leaves the machine and stays exempt.
+ const snapshot = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ keepLocalSecrets: true,
+ reportSecrets: true,
+ });
+ expect(payloadFileText(snapshot, "mcp.jsonc")).toContain(token);
+ });
+
+ it("blocks the export when shell quoting splits a known credential token", async () => {
+ // Bash removes the backslash at execution, handing the server one contiguous token.
+ const brokenToken = "ghp_a1b2c3d4e5f6g7h8i9\\j0k1l2m3n4o5p6q7r8";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: `mcp-grafana --token ${brokenToken}` } } })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
+ });
+
+ it("blocks continuation-split command credentials and keeps non-command strings verbatim", async () => {
+ // Bash removes backslash-newline entirely, handing the server one contiguous key,
+ // and the backstop's shell normalization reassembles the same token.
+ const brokenKey = "AKIA12345678\\\n90123456";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: `mcp-grafana --key ${brokenKey}` } } })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
+
+ // A non-command string is not shell input: nothing at runtime joins its
+ // fragments, so it publishes verbatim instead of manufacturing a block.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { notes: { command: "npx notes-mcp", toolAllowlist: [brokenKey] } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("AKIA12345678");
+ });
+
+ it("keeps quoted backslashes that the shell preserves from manufacturing tokens", async () => {
+ // Inside single quotes, and before a non-special character inside double quotes,
+ // Bash keeps the backslash, so the runtime argument never becomes one token.
+ for (const command of [
+ "mcp-grafana --pattern 'ghp_aaaaaaaaaa\\bbbbbbbbbb'",
+ 'mcp-grafana --pattern "ghp_aaaaaaaaaa\\bbbbbbbbbb"',
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa");
+ }
+ });
+
+ it("does not manufacture a credential block from a CRLF-broken command", async () => {
+ // Backslash before CRLF escapes only the CR, so no runtime join produces a token.
+ // The CR-bearing word makes the command machine-local, but creation must succeed
+ // rather than raise the no-override credential error.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern ghp_aaaaaaaaaa\\\r\nbbbbbbbbbb" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("keeps inert dollar literals from manufacturing tokens while active ones splice", async () => {
+ // Single-quoted and escaped dollars reach the process literally, so none of
+ // these can reassemble a token at runtime.
+ const inert = [
+ "mcp --pattern 'ghp_aaaaaaaaaa$NAMEbbbbbbbbbb'",
+ "mcp --pattern ghp_aaaaaaaaaa\\$NAMEbbbbbbbbbb",
+ "mcp --pattern 'ghp_aaaaaaaaaa$912345678901234567890'",
+ "mcp --pattern ghp_aaaaaaaaaa\\$912345678901234567890",
+ ];
+ for (const command of inert) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa");
+ }
+
+ // Expansions stay active inside double quotes, and the command itself could
+ // populate the parameter first, so those spellings go machine-local instead
+ // of publishing.
+ for (const command of [
+ 'mcp --pattern "ghp_aaaaaaaaaa$912345678901234567890"',
+ 'mcp --pattern "ghp_aaaaaaaaaa$NAMEbbbbbbbbbb"',
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes a command whose empty expansion would splice a credential token", async () => {
+ // Bash expands the unset variable to nothing, joining the fragments across the
+ // quote boundary that ends its name; the active expansion sends the command
+ // machine-local before anything publishes.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: 'mcp-grafana --token ghp_1234567890$NOPE"12345678901234567890"' },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("keeps a comment opened after a line continuation portable", async () => {
+ // The backslash-LF continuation vanishes before tokenization, so Bash reads
+ // `# TOKEN=hunter2` as the same comment it would be on one line; rewriting the
+ // prose would send a portable command machine-local.
+ const command = "mcp-server \\\n# TOKEN=hunter2";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(command);
+ });
+
+ it("consumes Bash-only word breaks so a NBSP-joined value cannot leak its tail", async () => {
+ // JS `\s` counts NBSP as whitespace, but Bash keeps it inside the word: the
+ // assignment's runtime value runs through it, so the tail must not stay published.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "TOKEN=public\u00a0hunter2 mcp-server" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(`TOKEN=${REDACTED_BACKUP_VALUE} mcp-server`);
+ });
+
+ it("localizes always-set special parameters instead of blocking or publishing", async () => {
+ // Their expansions produce token-charset output ($# is `0`, $0 the shell name), so
+ // `ghp_...$#` runs with a completed credential no scan of the spelling sees, while
+ // deleting them would manufacture no-override blocks; machine-local avoids both,
+ // and creation must succeed either way.
+ for (const command of [
+ "mcp --pattern ghp_aaaaaaaaaa$0bbbbbbbbbb",
+ "mcp --token ghp_aaaaaaaaaaaaaaaaaaa$#",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("stops the credential scan at a Bash comment but resumes on the next line", async () => {
+ // Bash discards everything from an unquoted `#` word to the newline, so the
+ // quote-separated prose there can never join into a runtime token.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: 'mcp-server # ghp_aaaaaaaaaa"bbbbbbbbbb"' } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe('mcp-server # ghp_aaaaaaaaaa"bbbbbbbbbb"');
+
+ // A continuation before the `#` disappears first, so the comment position
+ // survives the wrapped line and the scan still skips the prose.
+ const wrapped = 'mcp-server \\\n# ghp_aaaaaaaaaa"bbbbbbbbbb"';
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: wrapped } } })
+ );
+ const wrappedPayload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const wrappedMcp = jsonc.parse(payloadFileText(wrappedPayload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(wrappedMcp.servers.grafana.command).toBe(wrapped);
+
+ // Past the newline execution resumes, so the same splice there still blocks.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: 'mcp-server # note\nmcp2 --pattern ghp_aaaaaaaaaa"bbbbbbbbbb"' },
+ },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
+ it("decodes published MCP urls once before the credential backstop", async () => {
+ // A single URL parse yields the contiguous token from `%61`, so the encoded
+ // spelling publishes the same credential the literal one is blocked for.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { url: "https://example.com/mcp?value=ghp_%61b2c3d4e5f6g7h8i9j0k" } },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+
+ // One pass only: a double-encoded `%2561` reaches every client as the literal `%61`.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { url: "https://example.com/mcp?value=ghp_%2561b2c3d4e5f6g7h8i9j0k" },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_%2561");
+ });
+
+ it("collapses deterministic globs so a bracketed spelling cannot hide a token", async () => {
+ // `[8]` matches only `8`: pathname expansion can hand the process the contiguous
+ // token, and the published text collapses the same way for any reader.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern ghp_aaaaaaaaaa[8]aaaaaaaaa" } },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+
+ // A letter member is not deterministic: inherited nocaseglob makes `[P]` match a
+ // lowercase `p` file, so the runtime token differs from any textual collapse and
+ // the command goes machine-local instead.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --token gh[P]_1234567890abcdefghijklmnopqrstuvwxyz" } },
+ })
+ );
+ const localized = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const localizedMcp = jsonc.parse(payloadFileText(localized, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(localizedMcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+
+ // Quoting suppresses pathname expansion, so the same spelling stays publishable.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern 'ghp_aaaaaaaaaa[b]aaaaaaaaa'" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa[b]aaaaaaaaa");
+ });
+
+ it("localizes a multi-member glob class instead of publishing the pattern", async () => {
+ // `[px]` expands against whatever the working directory contains, so a file named
+ // for the credential hands the process the token while the pattern publishes; the
+ // whole command goes machine-local like every other undecidable construct.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern gh[px]_aaaaaaaaaaaaaaaaaaaa" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes nondeterministic wildcards instead of publishing them", async () => {
+ // A wildcard inside a known token prefix (`gh?_...`) expands to the credential
+ // when a matching file exists, and no textual scan of the published spelling can
+ // reconstruct that, so the command goes machine-local like the class spellings.
+ for (const command of [
+ "mcp --pattern gh?_aaaaaaaaaaaaaaaaaaaa",
+ "mcp --pattern ghp_aaaaaaaaaa*aaaaaaaaa",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes an escaped class member the collapse pass cannot reproduce", async () => {
+ // Bash still expands `[\\p]` against matching files, but the scan's deterministic
+ // collapse only reproduces the plain `[c]` spelling, so this form goes machine-local.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: {
+ command: "mcp --pattern gh[\\p]_12345678901234567890123456789012345678",
+ },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("classifies a long literal bracket run in linear time as machine-local", async () => {
+ // A regex restarting its `]` search at every bracket goes quadratic on this input;
+ // the analyzer must classify it in one pass and localize the unmatched brackets.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: `mcp --pattern ${"[".repeat(4096)}` } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes here-documents instead of scanning their bodies as words", async () => {
+ // The body's quotes reach the consumer literally, so word-rule quote removal would
+ // manufacture a no-override match from prose; machine-local keeps both sides right.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: 'cat < {
+ // Bash comments start at any word boundary (`cmd;# ...`), and where the grammar
+ // needed a word instead it errors without executing, so the prose cannot run.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: 'mcp-server;# ghp_aaaaaaaaaa"bbbbbbbbbb"' } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe('mcp-server;# ghp_aaaaaaaaaa"bbbbbbbbbb"');
+ });
+
+ it("localizes a command whose $! depends on execution state", async () => {
+ // `$!` is empty until the command string starts a background job and a PID after,
+ // so neither deleting it nor keeping it literal scans both runtimes correctly.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "true & mcp --pattern ghp_aaaaaaaaaa$!bbbbbbbbbb" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("keeps quoted braces from localizing an ordinary JSON argument", async () => {
+ // The comma sits inside quotes, so Bash never brace-expands it and the argument
+ // reaches the server literally; the command must stay portable.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: 'mcp-server --config \'{"a":1,"b":2}\'' } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe('mcp-server --config \'{"a":1,"b":2}\'');
+ });
+
+ it("keeps inert expansion syntax from localizing a portable command", async () => {
+ // Single-quoted and commented spellings never reach evaluation, so the command
+ // stays portable...
+ for (const command of [
+ "mcp-server --pattern '$(date)'",
+ "mcp-server --pattern '@(x|y)'",
+ // ANSI-C quoting is not recognized inside double quotes: `$'` there is the
+ // two literal characters the process receives.
+ "mcp-server --label \"price$'5'\"",
+ "mcp-server # regenerate with $(date)",
+ "mcp-server \\\n# regenerate with $(date)",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(command);
+ }
+
+ // ...while double quotes keep the expansion live, so that spelling still localizes.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: 'mcp-server --pattern "$(date)"' } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("keeps repeated-character credential placeholders reviewable", async () => {
+ // The canonical documentation spelling has no issued-token entropy, so it belongs
+ // to the reviewable digest flow rather than the no-override block.
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ "Use ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx as your token\n"
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payload.files.some((file) => file.path === "skills/demo/SKILL.md")).toBe(true);
+
+ // Padding a real-shaped token with an obvious run must not smuggle it past the
+ // backstop: the stripped remainder still matches.
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ "ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8xxxxxxxxxxxxxxxx\n"
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
+ it("removes URL tab and newline separators before the credential backstop", async () => {
+ // The WHATWG parser deletes embedded tab/newline before parsing, so a client's
+ // `new URL(config.url)` reconstructs the contiguous token the raw text splits.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { url: "https://example.com/mcp?value=ghp_aaaaaaaaaa\tb2c3d4e5f6" },
+ },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
+ it("localizes nested brace expansion an inner group would otherwise hide", async () => {
+ // Bash expands `gh{p,{x}}_...` into an argument carrying the contiguous token; a
+ // flat pattern stops at the inner non-expanding group and would publish it.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern gh{p,{x}}_1234567890abcdefghij" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+
+ // A comma between two single-member groups expands nothing and stays portable.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: "mcp --flag {a},{b}" } } })
+ );
+ const portable = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const portableMcp = jsonc.parse(payloadFileText(portable, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(portableMcp.servers.grafana.command).toBe("mcp --flag {a},{b}");
+ });
+
+ it("leaves comment prose alone while still redacting executable assignments", async () => {
+ // Bash never evaluates the suffix, so rewriting it would only cost portability...
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: "mcp-server # TOKEN=hunter2" } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe("mcp-server # TOKEN=hunter2");
+
+ // ...while the executable region before the comment still redacts normally.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "TOKEN=hunter2 mcp-server # NOTE=keep" } },
+ })
+ );
+ const redacted = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const redactedMcp = jsonc.parse(payloadFileText(redacted, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(redactedMcp.servers.grafana.command).toBe(
+ `TOKEN=${REDACTED_BACKUP_VALUE} mcp-server # NOTE=keep`
+ );
+ });
+
+ it("localizes positional expansions the command itself can populate", async () => {
+ // `set -- p` fills $1 before the expansion runs, so the runtime argument carries
+ // the contiguous token while every textual scan of the spelling misses it.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: "set -- p; mcp --token gh$1_1234567890abcdefghijklmnopqrstuvwxyz" },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes named expansions the command itself can populate", async () => {
+ // `for X in p` and `printf -v X p` set $X with no NAME=value word for the
+ // redaction to rewrite, so `gh$X'_'...` runs as the contiguous credential while
+ // every scan of the spelling sees only fragments.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: {
+ command: "for X in p; do mcp --token gh$X'_'K3vQ9rT2wY7bN4mJ6hL8cD1fG5sZ0aXe; done",
+ },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes extended glob patterns an inherited extglob would activate", async () => {
+ // With BASHOPTS=extglob in the inherited environment, `@(p|x)` is one active
+ // pathname pattern, and a matching credential-named file hands the process the
+ // contiguous token while the scans split at `(`, `|`, and `)`.
+ for (const command of [
+ "mcp --token gh@(p|x)_1234567890abcdefghij",
+ "mcp --token gh+(p)_1234567890abcdefghij",
+ "mcp --token gh!(q)_1234567890abcdefghij",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("normalizes active line continuations before the shell analyzers run", async () => {
+ // Bash deletes backslash-LF before any expansion, so a continuation can split
+ // syntax the analyzers must still see: a continuation between `$` and `(`
+ // still runs as command substitution, and one splitting a brace sequence's
+ // dots still expands, each yielding a contiguous credential.
+ for (const command of [
+ "mcp --token gh$\\\n(printf p)'_'K3vQ9rT2wY7bN4mJ6hL8cD1fG5sZ0aXe",
+ "mcp --token ghp_aaaaaaaaaaaaaaaaaaa{0.\\\n.0}",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+
+ // A wrapped command with nothing to redact keeps its original spelling...
+ const wrapped = "mcp-server \\\n --transport stdio";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: wrapped } } })
+ );
+ const portable = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const portableMcp = jsonc.parse(payloadFileText(portable, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(portableMcp.servers.grafana.command).toBe(wrapped);
+
+ // ...while a wrapped assignment goes machine-local whole: the marker's position
+ // is only defined in the unwrapped spelling Bash executes.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: "TOKEN=hunter2 \\\nmcp-server" } } })
+ );
+ const localized = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const localizedMcp = jsonc.parse(payloadFileText(localized, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(localizedMcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes reparse and file-synthesis constructs regardless of assignments", async () => {
+ // `eval` concatenates and reparses its arguments, dissolving a second quoting
+ // layer (`ghp_aaaaaaaaaa\\bbbbbbbbbb` loses one backslash per parse and runs
+ // contiguous), and a process substitution's inner script can synthesize a
+ // credential file; neither needs an assignment, so both localize on their own.
+ for (const command of [
+ "eval mcp --token ghp_aaaaaaaaaa\\\\bbbbbbbbbb",
+ "mcp --token-file <(printf ghp_aaaaaaaaaa;printf bbbbbbbbbb)",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+
+ // A word merely containing the letters stays an ordinary argument.
+ const portable = "run-mcp --formatter evaluate";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: portable } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(portable);
+ });
+
+ it("blocks GitLab tokens in collected files without an override", async () => {
+ // GitLab issued-only prefixes cover more than the PAT: CI job, OAuth app,
+ // feature-flag, mail, and agent tokens are issued the same way, and a generically
+ // named collected file must not publish any of them just because no path-based
+ // gate covers it. Assembled at runtime so this source file never holds a
+ // contiguous token-shaped string, which GitHub push protection would itself
+ // refuse.
+ for (const prefix of ["glpat-", "glcbt-", "gloas-", "glffct-", "glimt-", "glagent-"]) {
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ ["token: ", prefix, "K3vQ9rT2wY7bN4mJ6hL8", "\n"].join("")
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ }
+ });
+
+ it("localizes every command while Bash startup hooks are inherited", async () => {
+ // A sourced BASH_ENV file or an imported exported function can redefine any
+ // command word (`mcp(){ mcp --token "$2$3"; }` joins published fragments), so no
+ // word-level analysis binds while the stdio spawn inherits a hook.
+ const portable = "mcp-server --port 8080";
+ const fixture = JSON.stringify({ servers: { grafana: { command: portable } } });
+ for (const [name, value] of [
+ ["BASH_ENV", "./startup.sh"],
+ ["BASH_FUNC_mcp%%", "() { :; }"],
+ ]) {
+ await writeFixtureFile(muxRoot, "mcp.jsonc", fixture);
+ process.env[name] = value;
+ try {
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ } finally {
+ delete process.env[name];
+ }
+ }
+
+ // An empty BASH_ENV sources nothing, so analysis keeps its authority.
+ await writeFixtureFile(muxRoot, "mcp.jsonc", fixture);
+ process.env.BASH_ENV = "";
+ try {
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(portable);
+ } finally {
+ delete process.env.BASH_ENV;
+ }
+ });
+
+ it("publishes a portable command despite startup hooks in the ignored config env field", async () => {
+ // McpConfigService.normalizeEntry drops env from stdio entries, so a config-level
+ // BASH_ENV never reaches the spawn; localizing the command for it would only make a
+ // fresh-device restore drop the whole server. The env value itself stays redacted
+ // like every other ignored field.
+ const portable = "mcp-server --port 8080";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ hooked: { command: portable, env: { BASH_ENV: "./startup.sh" } },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { hooked: { command: string; env: string } };
+ };
+ expect(mcp.servers.hooked.command).toBe(portable);
+ expect(mcp.servers.hooked.env).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("scans a file holding a multi-megabyte repeated-character run", async () => {
+ // The run stripper must stay linear: a backreference regex exhausts V8's call
+ // stack near 4 MiB and rejected size-valid files before scanning them. The
+ // alternating U+212A KELVIN SIGN/k spelling forces every comparison through the
+ // non-ASCII fold path, which must treat the case-equivalent pair as one run without
+ // allocating per character.
+ for (const content of ["x".repeat(4 * 1024 * 1024), "\u212Ak".repeat(2 * 1024 * 1024)]) {
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", content);
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "skills/demo/SKILL.md")).toBe(content);
+ }
+ });
+
+ for (const [name, command] of [
+ ["npx call operands", "npx -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'"],
+ ["npm exec call operands", "npm exec -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'"],
+ [
+ "npm global option values before exec",
+ "npm --prefix /tmp exec -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
+ ],
+ [
+ "Rscript expression operands",
+ `Rscript -e 'system(paste0("mcp",intToUtf8(32),"--token",intToUtf8(32),"ghp_Abcdef1234","Klmno56789"))'`,
+ ],
+ [
+ "Lua expression operands",
+ `lua -e 'os.execute("mcp"..string.char(32).."--token"..string.char(32).."ghp_Abcdef1234".."Klmno56789")'`,
+ ],
+ [
+ "Elixir expression operands",
+ `elixir -e 'System.cmd("mcp",["--token","ghp_Abcdef1234"<>"Klmno567890123456"])'`,
+ ],
+ [
+ "IEx RPC evaluation operands",
+ `iex --rpc-eval node@host 'System.cmd("mcp",["--token","ghp_Abcdef1234"<>"Klmno567890123456"])'`,
+ ],
+ [
+ "GNU env split strings without assignments",
+ `env -S'mcp\\_--token\\_ghp_Abcdef1234""Klmno56789'`,
+ ],
+ ["GNU env clustered split strings", `env -ivS'mcp\\_--token\\_ghp_Abcdef1234""Klmno56789'`],
+ // find's -exec family hands its operands to execvp as a command.
+ ["GNU find exec callbacks", "find /tmp -maxdepth 0 -exec ~/.xum/skills/launch.txt \\;"],
+ ["GNU find execdir callbacks", "gfind /tmp -execdir mcp --token {} +"],
+ // Carriers run their operand as the command, so the wrapped word is checked
+ // like a command start; a dash option may take a separate value this scan
+ // cannot pair, so later words stay checked.
+ ["timeout-wrapped shells", "timeout 30 bash -c exit"],
+ ["option-carrying carrier wrappers", "nice -n 10 bash -c exit"],
+ ["env-terminated option lists", "env -- bash -c exit"],
+ ["keyword-guarded shells", "if bash -c exit; then mcp; fi"],
+ // coproc runs its command asynchronously; a function body runs at its call site.
+ ["coproc-wrapped shells", "coproc bash -c exit"],
+ ["named coproc compound bodies", "coproc PROXY { bash -c exit; }"],
+ ["function bodies", "function launch { bash -c exit; }; launch"],
+ ["prlimit-wrapped shells", "prlimit --nofile=256 bash -c exit"],
+ ["setpriv-wrapped shells", "setpriv --reuid 1000 bash -c exit"],
+ // setarch's leading arch operand is optional, so every operand is checked.
+ ["setarch-wrapped shells", "setarch linux64 bash -c exit"],
+ ["systemd-run-wrapped shells", "systemd-run --user --scope bash -c exit"],
+ ["systemd-inhibit-wrapped shells", "systemd-inhibit --what=idle bash -c exit"],
+ ["systemd-cat-wrapped shells", "systemd-cat -t mcp bash -c exit"],
+ ["CMake command mode", "cmake -E env bash -c exit"],
+ ] as const) {
+ it(`localizes ${name}`, async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+ }
+
+ it("localizes directly executed auto-published documents", async () => {
+ for (const command of [
+ `${muxRoot}/skills/launch.txt`,
+ `MODE=fast ${muxRoot}/skills/launch.txt`,
+ `env ${muxRoot}/skills/launch.txt`,
+ `env -u TOKEN ${muxRoot}/skills/launch.txt`,
+ `env env ${muxRoot}/agents/launch.md`,
+ // GNU env changes the wrapped utility's working directory before launch.
+ `env -C ${muxRoot} python3 skills/launch.txt`,
+ `env --chdir=${muxRoot} python3 agents/launch.md`,
+ `env --chd ${muxRoot}/skills tclsh launch.txt`,
+ `true; ${muxRoot}/agents/launch.md`,
+ `timeout 30 ${muxRoot}/skills/launch.txt`,
+ `nohup ${muxRoot}/skills/launch.txt`,
+ `prlimit ${muxRoot}/skills/launch.txt`,
+ `setpriv ${muxRoot}/skills/launch.txt`,
+ // Redirected stdin hands the same executable input to an interpreter.
+ `node < ${muxRoot}/skills/launch.txt`,
+ `sh 0< ${muxRoot}/skills/launch.txt`,
+ `systemd-run --user --scope ${muxRoot}/skills/launch.txt`,
+ `systemd-run --pipe --working-directory=${muxRoot} python3 skills/launch.txt`,
+ `systemd-run --working-directory ${muxRoot}/skills tclsh launch.txt`,
+ // start-stop-daemon executes the pathname supplied by --exec/--startas.
+ `start-stop-daemon --start --exec ${muxRoot}/skills/launch.txt --`,
+ `start-stop-daemon --start --startas=${muxRoot}/agents/launch.md --`,
+ `start-stop-daemon --start -a${muxRoot}/skills/launch.txt --`,
+ // The util-linux setarch hard links run their first operand as the program.
+ `linux32 ${muxRoot}/skills/launch.txt`,
+ `linux64 ${muxRoot}/agents/launch.md`,
+ `uname26 ${muxRoot}/skills/launch.txt`,
+ // Moving the working directory into the collected root lets any relative
+ // operand name a published document without spelling the root.
+ `cd ${muxRoot} && python3 skills/launch.txt`,
+ `pushd ${muxRoot}/skills; tclsh launch.txt`,
+ // A relative cd resolves against the tracked directory of an earlier cd.
+ `cd ${path.dirname(muxRoot)} && cd ${path.basename(muxRoot)} && python3 skills/launch.txt`,
+ // All shell-resolved executable inputs use the tracked cwd, not only a bare
+ // interpreter script operand.
+ `cd ${path.dirname(muxRoot)} && python3 ${path.basename(muxRoot)}/skills/launch.txt`,
+ `cd ${path.dirname(muxRoot)} && java -cp ${path.basename(muxRoot)}/skills/launch.txt Leak`,
+ `cd ${path.dirname(muxRoot)} && php -c${path.basename(muxRoot)}/skills/config.txt /opt/server.php`,
+ // The command word can follow the redirection, and an interpreter later in
+ // the same command still executes the redirected document.
+ `< ${muxRoot}/skills/launch.txt sh`,
+ `timeout 30 < ${muxRoot}/skills/launch.txt node`,
+ `cmake -P ${muxRoot}/skills/launch.txt`,
+ `ctest -S ${muxRoot}/skills/launch.txt`,
+ // Redundant separators and dot segments name the same collected file.
+ `${muxRoot}//skills/launch.txt`,
+ `env ${muxRoot}/./skills/launch.txt`,
+ // The Java launcher expands @argument-files into options before parsing.
+ `java @${muxRoot}/skills/args.txt`,
+ `java @/tmp/opts.txt --source 17 ${muxRoot}/skills/launch.txt`,
+ // Git searches this directory for external git- executables.
+ `git --exec-path=${muxRoot}/skills leak.txt`,
+ `mise exec -- python3 ${muxRoot}/skills/launch.txt`,
+ `mise x -- ${muxRoot}/agents/launch.md`,
+ `sqlite3 -init ${muxRoot}/skills/launch.txt :memory:`,
+ `sqlite3 -batch -init ${muxRoot}/agents/launch.md /tmp/data.db`,
+ "mise exec --command=launch.txt",
+ "mise x -c launch.txt",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("keeps executable names in argument positions portable", async () => {
+ // Only a word that can execute names an interpreter or wrapper; the same
+ // spelling as another program's argument is data, and localizing it would
+ // remove an otherwise portable server on a fresh-device restore.
+ for (const command of [
+ "mcp-server --shell bash --transport ssh --filter sed",
+ "mcp-server --runtime python3 -c config.toml",
+ "mcp-server --tool git config core.sshCommand ssh",
+ "nohup mcp-server --shell bash",
+ "mcp-server --mode find -exec /tmp/plugin",
+ "mcp-server --wrap prlimit --mode coproc",
+ "mcp-server < /tmp/input.json",
+ "mcp-server --launcher systemd-run",
+ "env -C /opt python3 app.py",
+ `mcp-server --launcher env --chdir=${muxRoot}`,
+ "git --exec-path=/usr/lib/git-core status",
+ `mcp-server --git-exec-path=${muxRoot}/skills`,
+ "start-stop-daemon --stop --exec /usr/bin/mcp-server --",
+ `mcp-server --launcher start-stop-daemon --exec ${muxRoot}/skills/config.txt`,
+ "cmake --build build --target package",
+ "mise --version",
+ "mise exec python@3.11",
+ "sqlite3 -init /tmp/init.sql :memory:",
+ `sqlite3 ${muxRoot}/skills/config.txt`,
+ `mcp-server --database sqlite3 -init ${muxRoot}/skills/config.txt`,
+ `mcp-server --launcher mise exec -- ${muxRoot}/skills/config.txt`,
+ // A control operator starts a new command, ending interpreter tracking.
+ "python3 --version && mcp-server -c config.toml",
+ // deno's entrypoint ends script tracking; later published paths are data.
+ `deno run /opt/server.ts ${muxRoot}/skills/config.txt`,
+ // The script operand after `--` ends tracking; later published paths are data.
+ `python3 -- /tmp/main.py ${muxRoot}/skills/config.txt`,
+ "ruby -C /opt app.rb --config skills/config.txt",
+ "ruby -C/opt app.rb",
+ "ruby -S /opt/tool.rb",
+ "php -c/tmp/php.ini /opt/server.php",
+ `java -cp /opt/app.jar Main ${muxRoot}/skills/config.txt`,
+ "systemd-run --working-directory=/opt node server.js",
+ `mcp-server --launcher systemd-run --working-directory=${muxRoot}`,
+ // Two-segment merge/diff keys hold settings, not driver commands.
+ "git config merge.conflictstyle diff3",
+ "java @/tmp/opts.txt Main --port 8080",
+ "jshell --startup=/tmp/snippets.jsh /tmp/main.jsh",
+ "hash -r; mcp-server",
+ "cd /app && node server.js",
+ // A relative cd from the server's own unknown cwd stays portable, matching
+ // the relative-operand policy.
+ "cd .xum && python3 skills/launch.txt",
+ // A non-interpreter, or an interpreter after its script boundary, consumes
+ // redirected documents as data rather than source code.
+ `mcp-server < ${muxRoot}/skills/config.txt`,
+ `python3 /tmp/main.py < ${muxRoot}/skills/config.txt`,
+ // A foreign jar ends option tracking; later published paths are its data.
+ `java -jar /opt/app.jar ${muxRoot}/skills/config.txt`,
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(command);
+ }
+ });
+
+ it("resolves ~ spellings against a settings root under the home directory", async () => {
+ const homeRoot = await fs.mkdtemp(path.join(os.homedir(), ".xum-backup-test-"));
+ try {
+ // Both the bare and the named-home spellings expand to the same directory.
+ for (const command of [
+ `python3 ~/${path.basename(homeRoot)}/skills/launch.txt`,
+ `python3 ~${os.userInfo().username}/${path.basename(homeRoot)}/skills/launch.txt`,
+ // Relative cd chains resolve against the tracked directory, and a bare
+ // cd goes home.
+ `cd ~ && cd ${path.basename(homeRoot)} && python3 skills/launch.txt`,
+ `cd && cd ${path.basename(homeRoot)}/skills && tclsh launch.txt`,
+ ]) {
+ await writeFixtureFile(
+ homeRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot: homeRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ } finally {
+ await fs.rm(homeRoot, { recursive: true, force: true });
+ }
+ });
+
+ it("localizes the canonical target spelling of a symlinked settings root", async () => {
+ // Collection follows a symlinked root to its target, so a command can name the
+ // same collected files through the canonical spelling.
+ const canonicalRoot = await fs.realpath(muxRoot);
+ const linkedRoot = path.join(tempDir, "linked-root");
+ await fs.symlink(canonicalRoot, linkedRoot, "dir");
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { private: { command: `python3 ${canonicalRoot}/skills/launch.txt` } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot: linkedRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes direct executable operands symlinked into the collected root", async () => {
+ await fs.mkdir(path.join(muxRoot, "skills"), { recursive: true });
+ const target = path.join(muxRoot, "skills", "launch.txt");
+ await fs.writeFile(target, "program");
+ const linkedScript = path.join(tempDir, "linked-script");
+ await fs.symlink(target, linkedScript, "file");
+ const foreignTarget = path.join(tempDir, "foreign-script.txt");
+ await fs.writeFile(foreignTarget, "program");
+ const foreignLink = path.join(tempDir, "foreign-script");
+ await fs.symlink(foreignTarget, foreignLink, "file");
+ for (const [command, expected] of [
+ [`python3 ${linkedScript}`, REDACTED_BACKUP_VALUE],
+ [`python3 ${foreignLink}`, `python3 ${foreignLink}`],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes bare commands resolvable through a PATH entry inside the root", async () => {
+ // The spawned server inherits this process's PATH, so an entry inside the
+ // collected root makes a published executable document reachable by name.
+ const originalPath = process.env.PATH;
+ process.env.PATH = `${muxRoot}/skills${path.delimiter}${originalPath ?? ""}`;
+ try {
+ for (const [command, expected] of [
+ ["launch.txt --serve", REDACTED_BACKUP_VALUE],
+ ["ruby -S launch.txt", REDACTED_BACKUP_VALUE],
+ ["rubyw -S launch.txt", REDACTED_BACKUP_VALUE],
+ ["perl -S launch.txt", REDACTED_BACKUP_VALUE],
+ ["wperl -S launch.txt", REDACTED_BACKUP_VALUE],
+ // A name that does not resolve to a published document stays portable.
+ ["mcp-server --transport stdio", "mcp-server --transport stdio"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalPath === undefined) delete process.env.PATH;
+ else process.env.PATH = originalPath;
+ }
+ });
+
+ it("localizes bare commands through a PATH entry symlinked into the root", async () => {
+ // A PATH entry outside the root can still reach published documents through
+ // a symlink, so the filter canonicalizes each entry before testing it.
+ await fs.mkdir(path.join(muxRoot, "skills"), { recursive: true });
+ const linkedBin = path.join(tempDir, "xum-bin");
+ await fs.symlink(path.join(muxRoot, "skills"), linkedBin, "dir");
+ const foreignTarget = path.join(tempDir, "foreign-bin");
+ await fs.mkdir(foreignTarget);
+ const foreignLink = path.join(tempDir, "foreign-link");
+ await fs.symlink(foreignTarget, foreignLink, "dir");
+ const originalPath = process.env.PATH;
+ try {
+ for (const [pathEntry, command, expected] of [
+ [linkedBin, "launch.txt --serve", REDACTED_BACKUP_VALUE],
+ // A published name stays portable when only a foreign symlink precedes it.
+ [foreignLink, "launch.txt --serve", "launch.txt --serve"],
+ [linkedBin, "mcp-server --transport stdio", "mcp-server --transport stdio"],
+ ] as const) {
+ process.env.PATH = `${pathEntry}${path.delimiter}${originalPath ?? ""}`;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalPath === undefined) delete process.env.PATH;
+ else process.env.PATH = originalPath;
+ }
+ });
+
+ it("resolves relative cd targets through inherited CDPATH", async () => {
+ const originalCdPath = process.env.CDPATH;
+ const command = "cd skills && ruby launch.txt";
+ try {
+ for (const [cdPath, expected] of [
+ [muxRoot, REDACTED_BACKUP_VALUE],
+ ["/opt", command],
+ ] as const) {
+ process.env.CDPATH = cdPath;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalCdPath === undefined) delete process.env.CDPATH;
+ else process.env.CDPATH = originalCdPath;
+ }
+ });
+
+ it("localizes inherited Node preload options", async () => {
+ const originalNodeOptions = process.env.NODE_OPTIONS;
+ try {
+ for (const [nodeOptions, command, expected] of [
+ [`--require=${muxRoot}/skills/launch.txt`, "node /opt/server.js", REDACTED_BACKUP_VALUE],
+ [`--import ${muxRoot}/agents/launch.md`, "nodejs /opt/server.js", REDACTED_BACKUP_VALUE],
+ // npm-shipped launchers are node scripts and inherit the same preloads.
+ [`--require=${muxRoot}/skills/launch.txt`, "npx -y mcp-server", REDACTED_BACKUP_VALUE],
+ [`--require=${muxRoot}/skills/launch.txt`, "npm exec mcp-server", REDACTED_BACKUP_VALUE],
+ [`--require=${muxRoot}/skills/launch.txt`, "corepack pnpm start", REDACTED_BACKUP_VALUE],
+ [
+ `--openssl-shared-config --openssl-config=${muxRoot}/skills/config.txt`,
+ "node /opt/server.js",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ `--openssl-config ${muxRoot}/skills/config.txt --openssl-shared-config`,
+ "node /opt/server.js",
+ REDACTED_BACKUP_VALUE,
+ ],
+ ["--require=/opt/register.js", "python3 /opt/server.py", "python3 /opt/server.py"],
+ [
+ `--openssl-config=${muxRoot}/skills/config.txt`,
+ "node /opt/server.js",
+ "node /opt/server.js",
+ ],
+ [
+ "--openssl-shared-config --openssl-config=/etc/ssl/openssl.cnf",
+ "node /opt/server.js",
+ "node /opt/server.js",
+ ],
+ ["--max-old-space-size=4096", "node /opt/server.js", "node /opt/server.js"],
+ ] as const) {
+ process.env.NODE_OPTIONS = nodeOptions;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalNodeOptions === undefined) delete process.env.NODE_OPTIONS;
+ else process.env.NODE_OPTIONS = originalNodeOptions;
+ }
+ });
+
+ it("localizes Node startup snapshot blobs", async () => {
+ for (const [command, expected] of [
+ [`node --snapshot-blob=${muxRoot}/skills/launch.txt /opt/server.js`, REDACTED_BACKUP_VALUE],
+ [
+ "node --snapshot-blob=/opt/snapshot.blob /opt/server.js",
+ "node --snapshot-blob=/opt/snapshot.blob /opt/server.js",
+ ],
+ [
+ `mcp-server --snapshot-blob=${muxRoot}/skills/launch.txt`,
+ `mcp-server --snapshot-blob=${muxRoot}/skills/launch.txt`,
+ ],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes interactive Python under an inherited published startup file", async () => {
+ const originalStartup = process.env.PYTHONSTARTUP;
+ try {
+ for (const [startup, command, expected] of [
+ [`${muxRoot}/skills/launch.txt`, "python3 -i", REDACTED_BACKUP_VALUE],
+ // The interactive letter clusters like the eval letter does.
+ [`${muxRoot}/skills/launch.txt`, "python3 -qi", REDACTED_BACKUP_VALUE],
+ // A non-interactive launcher never reads the startup file.
+ [`${muxRoot}/skills/launch.txt`, "python3 /opt/server.py", "python3 /opt/server.py"],
+ // A foreign startup file is not collected, so nothing published executes.
+ ["/tmp/rc.py", "python3 -i", "python3 -i"],
+ ] as const) {
+ process.env.PYTHONSTARTUP = startup;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalStartup === undefined) delete process.env.PYTHONSTARTUP;
+ else process.env.PYTHONSTARTUP = originalStartup;
+ }
+ });
+
+ it("localizes PHP launchers under an inherited published PHPRC", async () => {
+ const originalPhpRc = process.env.PHPRC;
+ try {
+ for (const [phpRc, command, expected] of [
+ [`${muxRoot}/skills/php-config.txt`, "php /opt/server.php", REDACTED_BACKUP_VALUE],
+ [`${muxRoot}/skills/php-config.txt`, "php8.3 /opt/server.php", REDACTED_BACKUP_VALUE],
+ // A non-PHP launcher never reads PHPRC.
+ [`${muxRoot}/skills/php-config.txt`, "python3 /opt/server.py", "python3 /opt/server.py"],
+ // A foreign config file is not published by this backup.
+ ["/etc/php.ini", "php /opt/server.php", "php /opt/server.php"],
+ ] as const) {
+ process.env.PHPRC = phpRc;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalPhpRc === undefined) delete process.env.PHPRC;
+ else process.env.PHPRC = originalPhpRc;
+ }
+ });
+
+ it("localizes JVM launchers under inherited published Java agents", async () => {
+ const variableNames = ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [variable, value, command, expected] of [
+ [
+ "JAVA_TOOL_OPTIONS",
+ `-javaagent:${muxRoot}/skills/launch.txt`,
+ "java com.example.Server",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "_JAVA_OPTIONS",
+ `-agentpath:${muxRoot}/agents/launch.md=debug`,
+ "javaw com.example.Server",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "JDK_JAVA_OPTIONS",
+ `-javaagent:${muxRoot}/skills/launch.txt`,
+ "jshell --version",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A non-JVM command never loads the agent.
+ [
+ "JAVA_TOOL_OPTIONS",
+ `-javaagent:${muxRoot}/skills/launch.txt`,
+ "python3 /opt/server.py",
+ "python3 /opt/server.py",
+ ],
+ // A foreign agent archive is not published by this backup.
+ ["JAVA_TOOL_OPTIONS", "-javaagent:/opt/agent.jar", "java Main", "java Main"],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ process.env[variable] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes Lua launchers under an inherited published startup file", async () => {
+ const variableNames = ["LUA_INIT", "LUA_INIT_5_4"] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [variable, value, command, expected] of [
+ ["LUA_INIT", `@${muxRoot}/skills/launch.txt`, "lua /opt/server.lua", REDACTED_BACKUP_VALUE],
+ [
+ "LUA_INIT_5_4",
+ `@${muxRoot}/agents/launch.md`,
+ "luajit /opt/server.lua",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A non-Lua command never runs the startup hook.
+ [
+ "LUA_INIT",
+ `@${muxRoot}/skills/launch.txt`,
+ "python3 /opt/server.py",
+ "python3 /opt/server.py",
+ ],
+ // A non-@ value is inline code, and a foreign @file is not collected.
+ ["LUA_INIT", "print('ready')", "lua /opt/server.lua", "lua /opt/server.lua"],
+ ["LUA_INIT", "@/opt/init.lua", "lua /opt/server.lua", "lua /opt/server.lua"],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ process.env[variable] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes commands under an inherited published dynamic-loader preload", async () => {
+ const variableNames = [
+ "LD_PRELOAD",
+ "LD_AUDIT",
+ "DYLD_INSERT_LIBRARIES",
+ "LD_LIBRARY_PATH",
+ "DYLD_LIBRARY_PATH",
+ "DYLD_FALLBACK_LIBRARY_PATH",
+ ] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [env, expected] of [
+ [{ LD_PRELOAD: `${muxRoot}/skills/launch.txt` }, REDACTED_BACKUP_VALUE],
+ // glibc also splits the preload list on colons and spaces.
+ [{ LD_PRELOAD: `/opt/lib/probe.so:${muxRoot}/skills/launch.txt` }, REDACTED_BACKUP_VALUE],
+ [{ LD_AUDIT: `${muxRoot}/skills/launch.txt` }, REDACTED_BACKUP_VALUE],
+ [{ DYLD_INSERT_LIBRARIES: `${muxRoot}/agents/launch.md` }, REDACTED_BACKUP_VALUE],
+ // A slashless name resolves through the inherited loader search path.
+ [{ LD_LIBRARY_PATH: `${muxRoot}/skills`, LD_PRELOAD: "launch.txt" }, REDACTED_BACKUP_VALUE],
+ [
+ { DYLD_LIBRARY_PATH: `${muxRoot}/skills`, DYLD_INSERT_LIBRARIES: "launch.txt" },
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A foreign preload is not a collected document.
+ [{ LD_PRELOAD: "/opt/lib/probe.so" }, "mcp-server --transport stdio"],
+ // A slashless name without a published search entry stays portable.
+ [{ LD_PRELOAD: "launch.txt" }, "mcp-server --transport stdio"],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ for (const [name, value] of Object.entries(env)) process.env[name] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command: "mcp-server --transport stdio" } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes Python launchers under an inherited published PYTHONPATH archive", async () => {
+ const originalPythonPath = process.env.PYTHONPATH;
+ try {
+ for (const [pythonPath, command, expected] of [
+ [`${muxRoot}/skills/launch.txt`, "python3 -m leak", REDACTED_BACKUP_VALUE],
+ // Search-path lists split on the platform delimiter.
+ [
+ `/opt/lib${path.delimiter}${muxRoot}/skills/launch.txt`,
+ "python3 -m leak",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Other launchers do not read PYTHONPATH.
+ [
+ `${muxRoot}/skills/launch.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ // A foreign archive is not a collected document.
+ ["/opt/lib/modules.zip", "python3 -m leak", "python3 -m leak"],
+ ] as const) {
+ process.env.PYTHONPATH = pythonPath;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalPythonPath === undefined) delete process.env.PYTHONPATH;
+ else process.env.PYTHONPATH = originalPythonPath;
+ }
+ });
+
+ it("localizes Java launchers under an inherited published CLASSPATH archive", async () => {
+ const originalClassPath = process.env.CLASSPATH;
+ try {
+ for (const [classPath, command, expected] of [
+ [`${muxRoot}/skills/launch.txt`, "java Leak", REDACTED_BACKUP_VALUE],
+ [
+ `/opt/lib${path.delimiter}${muxRoot}/skills/launch.txt`,
+ "java Leak",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Other launchers do not read CLASSPATH.
+ [
+ `${muxRoot}/skills/launch.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ // A foreign archive is not a collected document.
+ ["/opt/lib/leak.jar", "java Leak", "java Leak"],
+ ] as const) {
+ process.env.CLASSPATH = classPath;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalClassPath === undefined) delete process.env.CLASSPATH;
+ else process.env.CLASSPATH = originalClassPath;
+ }
+ });
+
+ it("canonicalizes symlinked inherited environment entries", async () => {
+ await fs.mkdir(path.join(muxRoot, "skills"), { recursive: true });
+ await fs.writeFile(path.join(muxRoot, "skills", "launch.txt"), "leak");
+ const linkedDir = path.join(tempDir, "xum-lib");
+ await fs.symlink(path.join(muxRoot, "skills"), linkedDir, "dir");
+ const linkedFile = path.join(tempDir, "linked-archive");
+ await fs.symlink(path.join(muxRoot, "skills", "launch.txt"), linkedFile, "file");
+ const foreignFile = path.join(tempDir, "foreign.txt");
+ await fs.writeFile(foreignFile, "data");
+ const foreignLink = path.join(tempDir, "foreign-archive");
+ await fs.symlink(foreignFile, foreignLink, "file");
+ const variableNames = [
+ "LD_LIBRARY_PATH",
+ "LD_PRELOAD",
+ "PYTHONPATH",
+ "CLASSPATH",
+ "PYTHONSTARTUP",
+ ] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [env, command, expected] of [
+ // A loader search directory symlinked into the root resolves the preload.
+ [
+ { LD_LIBRARY_PATH: linkedDir, LD_PRELOAD: "launch.txt" },
+ "mcp-server --transport stdio",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A preload spelling symlinked to a published document localizes.
+ [{ LD_PRELOAD: linkedFile }, "mcp-server --transport stdio", REDACTED_BACKUP_VALUE],
+ [{ PYTHONPATH: linkedFile }, "python3 -m leak", REDACTED_BACKUP_VALUE],
+ [{ CLASSPATH: linkedFile }, "java Leak", REDACTED_BACKUP_VALUE],
+ [{ PYTHONSTARTUP: linkedFile }, "python3 -i", REDACTED_BACKUP_VALUE],
+ // A symlink to a foreign file stays portable.
+ [{ PYTHONPATH: foreignLink }, "python3 -m leak", "python3 -m leak"],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ for (const [name, value] of Object.entries(env)) process.env[name] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes Java launchers under inherited published boot class paths", async () => {
+ const variableNames = ["JAVA_TOOL_OPTIONS", "_JAVA_OPTIONS", "JDK_JAVA_OPTIONS"] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [variable, value, command, expected] of [
+ [
+ "JAVA_TOOL_OPTIONS",
+ `-Xbootclasspath/a:${muxRoot}/skills/launch.txt`,
+ "java Leak",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "_JAVA_OPTIONS",
+ `-Xbootclasspath:${muxRoot}/skills/launch.txt`,
+ "java Leak",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Boot class paths split on the platform delimiter.
+ [
+ "JDK_JAVA_OPTIONS",
+ `-Xbootclasspath/p:/opt/lib${path.delimiter}${muxRoot}/skills/launch.txt`,
+ "java Leak",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // The JVM expands inherited @argument files into further options.
+ ["JDK_JAVA_OPTIONS", `@${muxRoot}/skills/options.txt`, "java Leak", REDACTED_BACKUP_VALUE],
+ ["JDK_JAVA_OPTIONS", "@/opt/options.txt", "java Leak", "java Leak"],
+ [
+ "JDK_JAVA_OPTIONS",
+ `--patch-module leak=${muxRoot}/skills/launch.txt`,
+ "java --module-path /opt/modules -m leak/leak.Main",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "JAVA_TOOL_OPTIONS",
+ `--module-path=${muxRoot}/skills/launch.txt`,
+ "java -m leak/leak.Main",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Other launchers do not consult the JVM option variables.
+ [
+ "JAVA_TOOL_OPTIONS",
+ `-Xbootclasspath/a:${muxRoot}/skills/launch.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ // A foreign archive is not a collected document.
+ ["JAVA_TOOL_OPTIONS", "-Xbootclasspath/a:/opt/lib/leak.jar", "java Leak", "java Leak"],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ process.env[variable] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes direct java boot-class-path archives", async () => {
+ for (const [command, expected] of [
+ [`java -javaagent:${muxRoot}/skills/launch.txt Main`, REDACTED_BACKUP_VALUE],
+ [`java -agentpath:${muxRoot}/skills/launch.txt=trace Main`, REDACTED_BACKUP_VALUE],
+ ["java -javaagent:/opt/agent.jar Main", "java -javaagent:/opt/agent.jar Main"],
+ [
+ `java --patch-module leak=${muxRoot}/skills/launch.txt -m leak/leak.Main`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ [`java --module-path=${muxRoot}/skills/launch.txt -m leak/leak.Main`, REDACTED_BACKUP_VALUE],
+ [`java -Xbootclasspath/a:${muxRoot}/skills/launch.txt Leak`, REDACTED_BACKUP_VALUE],
+ [`java -Xbootclasspath:${muxRoot}/skills/launch.txt Leak`, REDACTED_BACKUP_VALUE],
+ // A foreign archive is not a collected document.
+ [
+ "java -Xbootclasspath/a:/opt/lib/leak.jar Leak",
+ "java -Xbootclasspath/a:/opt/lib/leak.jar Leak",
+ ],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes sqlite3 command options", async () => {
+ for (const [command, expected] of [
+ // -cmd hands its operand to SQLite's own parse before stdin.
+ ["sqlite3 -cmd .dump :memory:", REDACTED_BACKUP_VALUE],
+ ["sqlite3 --cmd .dump :memory:", REDACTED_BACKUP_VALUE],
+ // The second positional operand is SQL that SQLite evaluates.
+ [`sqlite3 :memory: ".read ${muxRoot}/skills/launch.txt"`, REDACTED_BACKUP_VALUE],
+ ["sqlite3 :memory: .dump", REDACTED_BACKUP_VALUE],
+ // Both dash spellings of -init name the startup file.
+ [`sqlite3 --init ${muxRoot}/skills/launch.txt :memory:`, REDACTED_BACKUP_VALUE],
+ ["sqlite3 --init /opt/init.sql :memory:", "sqlite3 --init /opt/init.sql :memory:"],
+ ["sqlite3 :memory:", "sqlite3 :memory:"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes git launchers under inherited config overrides", async () => {
+ const variableNames = [
+ "GIT_CONFIG",
+ "GIT_CONFIG_PARAMETERS",
+ "GIT_CONFIG_COUNT",
+ "GIT_CONFIG_KEY_0",
+ "GIT_CONFIG_VALUE_0",
+ "GIT_CONFIG_GLOBAL",
+ "GIT_CONFIG_SYSTEM",
+ "GIT_SSH_COMMAND",
+ "GIT_PROXY_COMMAND",
+ "GIT_SSH",
+ "GIT_ASKPASS",
+ "SSH_ASKPASS",
+ "GIT_EXEC_PATH",
+ "GIT_EDITOR",
+ "GIT_SEQUENCE_EDITOR",
+ "GIT_PAGER",
+ "GIT_EXTERNAL_DIFF",
+ "VISUAL",
+ "EDITOR",
+ "PAGER",
+ ] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [env, command, expected] of [
+ // The deprecated carrier has a private quoting grammar, so any
+ // non-empty value conservatively localizes Git launchers.
+ [
+ {
+ GIT_CONFIG_PARAMETERS: `'core.sshCommand'='${muxRoot}/skills/launch.txt'`,
+ },
+ "git ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [{ GIT_CONFIG_PARAMETERS: "'user.name'='xum'" }, "git fetch origin", REDACTED_BACKUP_VALUE],
+ // GIT_CONFIG replaces the file Git reads, like the global/system selectors.
+ [
+ { GIT_CONFIG: `${muxRoot}/skills/gitconfig.txt` },
+ "git fetch origin",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // An inherited command-scope entry with a sensitive key fails closed.
+ [
+ {
+ GIT_CONFIG_COUNT: "1",
+ GIT_CONFIG_KEY_0: "core.sshCommand",
+ GIT_CONFIG_VALUE_0: `${muxRoot}/skills/launch.txt`,
+ },
+ "git ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A published replacement config file is read and applied by Git.
+ [
+ { GIT_CONFIG_GLOBAL: `${muxRoot}/skills/gitconfig.txt` },
+ "git fetch origin",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ { GIT_CONFIG_SYSTEM: `${muxRoot}/skills/gitconfig.txt` },
+ "git fetch origin",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Git executes inherited SSH commands and direct helper programs.
+ [
+ { GIT_SSH_COMMAND: `${muxRoot}/skills/launch.txt --ssh` },
+ "git ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ { GIT_PROXY_COMMAND: `${muxRoot}/skills/launch.txt --proxy` },
+ "git ls-remote git://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ { GIT_SSH: `${muxRoot}/skills/launch.txt` },
+ "git ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ { GIT_ASKPASS: `${muxRoot}/skills/launch.txt` },
+ "git fetch origin",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ { SSH_ASKPASS: `${muxRoot}/skills/launch.txt` },
+ "git fetch origin",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Git also executes inherited editor, pager, and diff commands.
+ [{ GIT_EDITOR: `${muxRoot}/skills/launch.txt` }, "git commit", REDACTED_BACKUP_VALUE],
+ [
+ { GIT_SEQUENCE_EDITOR: `${muxRoot}/skills/launch.txt` },
+ "git rebase -i HEAD~2",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [{ GIT_PAGER: `${muxRoot}/skills/launch.txt` }, "git log", REDACTED_BACKUP_VALUE],
+ [{ GIT_EXTERNAL_DIFF: `${muxRoot}/skills/launch.txt` }, "git diff", REDACTED_BACKUP_VALUE],
+ // The helper search directory can supply a published git subprogram.
+ [{ GIT_EXEC_PATH: `${muxRoot}/skills` }, "git launch.txt", REDACTED_BACKUP_VALUE],
+ // A foreign direct SSH helper stays portable.
+ [
+ { GIT_SSH: "/usr/bin/ssh" },
+ "git ls-remote ssh://example.invalid/repo",
+ "git ls-remote ssh://example.invalid/repo",
+ ],
+ // A data key stays portable.
+ [
+ { GIT_CONFIG_COUNT: "1", GIT_CONFIG_KEY_0: "user.name", GIT_CONFIG_VALUE_0: "xum" },
+ "git fetch origin",
+ "git fetch origin",
+ ],
+ // Git ignores entries at or past the declared count.
+ [
+ {
+ GIT_CONFIG_COUNT: "0",
+ GIT_CONFIG_KEY_0: "core.sshCommand",
+ GIT_CONFIG_VALUE_0: "probe",
+ },
+ "git fetch origin",
+ "git fetch origin",
+ ],
+ // A foreign config file is not a collected document.
+ [{ GIT_CONFIG_GLOBAL: "/etc/gitconfig" }, "git fetch origin", "git fetch origin"],
+ // Other launchers do not read Git configuration.
+ [
+ { GIT_CONFIG_GLOBAL: `${muxRoot}/skills/gitconfig.txt` },
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ for (const [name, value] of Object.entries(env)) process.env[name] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes direct dynamic-loader preload and audit operands", async () => {
+ for (const [command, expected] of [
+ [`/usr/bin/ld.so --preload ${muxRoot}/skills/launch.txt /bin/true`, REDACTED_BACKUP_VALUE],
+ [
+ `/lib64/ld-linux-x86-64.so.2 --audit=${muxRoot}/skills/launch.txt /bin/true`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ `ld.so --library-path ${muxRoot}/skills --preload launch.txt /bin/true`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "/usr/bin/ld.so --preload /opt/lib/probe.so /bin/true",
+ "/usr/bin/ld.so --preload /opt/lib/probe.so /bin/true",
+ ],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes Clang forwarded plugin loads", async () => {
+ for (const [command, expected] of [
+ [`clang -Xclang -load -Xclang ${muxRoot}/skills/launch.txt source.c`, REDACTED_BACKUP_VALUE],
+ [
+ `x86_64-linux-gnu-clang++-18 -Xclang -load -Xclang ${muxRoot}/skills/launch.txt source.cc`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "clang -Xclang -load -Xclang /opt/plugin.so source.c",
+ "clang -Xclang -load -Xclang /opt/plugin.so source.c",
+ ],
+ ["clang source.c", "clang source.c"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes CMake under an inherited published toolchain file", async () => {
+ const originalToolchain = process.env.CMAKE_TOOLCHAIN_FILE;
+ try {
+ for (const [toolchain, command, expected] of [
+ [`${muxRoot}/skills/launch.txt`, "cmake -S /opt/project -B build", REDACTED_BACKUP_VALUE],
+ [
+ "/opt/toolchain.cmake",
+ "cmake -S /opt/project -B build",
+ "cmake -S /opt/project -B build",
+ ],
+ [
+ `${muxRoot}/skills/launch.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ ] as const) {
+ process.env.CMAKE_TOOLCHAIN_FILE = toolchain;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalToolchain === undefined) delete process.env.CMAKE_TOOLCHAIN_FILE;
+ else process.env.CMAKE_TOOLCHAIN_FILE = originalToolchain;
+ }
+ });
+
+ it("localizes GNU Make under inherited published MAKEFILES", async () => {
+ const originalMakefiles = process.env.MAKEFILES;
+ try {
+ for (const [makefiles, command, expected] of [
+ [`${muxRoot}/skills/launch.txt`, "make -C /opt/project", REDACTED_BACKUP_VALUE],
+ [
+ `/opt/base.mk ${muxRoot}/skills/launch.txt`,
+ "gmake -C /opt/project",
+ REDACTED_BACKUP_VALUE,
+ ],
+ ["/opt/base.mk", "make -C /opt/project", "make -C /opt/project"],
+ [
+ `${muxRoot}/skills/launch.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ ] as const) {
+ process.env.MAKEFILES = makefiles;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalMakefiles === undefined) delete process.env.MAKEFILES;
+ else process.env.MAKEFILES = originalMakefiles;
+ }
+ });
+
+ it("localizes Perl debugger invocations under inherited PERL5DB", async () => {
+ const originalPerl5db = process.env.PERL5DB;
+ try {
+ for (const [perl5db, command, expected] of [
+ [
+ `BEGIN { do q(${muxRoot}/skills/launch.txt) }`,
+ "perl -d /opt/server.pl",
+ REDACTED_BACKUP_VALUE,
+ ],
+ ["sub DB::DB {}", "wperl -dt /opt/server.pl", REDACTED_BACKUP_VALUE],
+ // Without a debugger option, PERL5DB is not executed.
+ [
+ `BEGIN { do q(${muxRoot}/skills/launch.txt) }`,
+ "perl /opt/server.pl",
+ "perl /opt/server.pl",
+ ],
+ // An empty hook is inert.
+ ["", "perl -d /opt/server.pl", "perl -d /opt/server.pl"],
+ ] as const) {
+ process.env.PERL5DB = perl5db;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalPerl5db === undefined) delete process.env.PERL5DB;
+ else process.env.PERL5DB = originalPerl5db;
+ }
+ });
+
+ it("localizes Perl when inherited PERL5OPT enables an inherited debugger", async () => {
+ const variableNames = ["PERL5DB", "PERL5OPT"] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [env, command, expected] of [
+ [
+ {
+ PERL5DB: `BEGIN { do q(${muxRoot}/skills/launch.txt) }`,
+ PERL5OPT: "-d",
+ },
+ "perl /opt/server.pl",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // Both inherited pieces are required.
+ [{ PERL5DB: "sub DB::DB {}" }, "perl /opt/server.pl", "perl /opt/server.pl"],
+ [{ PERL5OPT: "-d" }, "perl /opt/server.pl", "perl /opt/server.pl"],
+ // Other launchers ignore Perl's environment.
+ [
+ { PERL5DB: "sub DB::DB {}", PERL5OPT: "-d" },
+ "python3 /opt/server.py",
+ "python3 /opt/server.py",
+ ],
+ ] as const) {
+ for (const name of variableNames) delete process.env[name];
+ for (const [name, value] of Object.entries(env)) process.env[name] = value;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ for (const [index, name] of variableNames.entries()) {
+ const original = originals[index];
+ if (original === undefined) delete process.env[name];
+ else process.env[name] = original;
+ }
+ }
+ });
+
+ it("localizes uv run command invocations", async () => {
+ for (const [command, expected] of [
+ [`uv run python3 ${muxRoot}/skills/launch.txt`, REDACTED_BACKUP_VALUE],
+ ["uv --directory /opt run python3 /opt/server.py", REDACTED_BACKUP_VALUE],
+ ["uv tool run probe", REDACTED_BACKUP_VALUE],
+ // Other subcommands do not hand their later operands to exec.
+ ["uv pip install run", "uv pip install run"],
+ ["uv sync", "uv sync"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes GDB command files and eval options", async () => {
+ for (const [command, expected] of [
+ [`gdb -nx -batch -x ${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`gdb --command=${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`gdb-multiarch -ix ${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ ["gdb -ex run app", REDACTED_BACKUP_VALUE],
+ ["gdb --eval-command=run app", REDACTED_BACKUP_VALUE],
+ // A foreign command file and a plain invocation stay portable.
+ ["gdb -x /opt/init.gdb app", "gdb -x /opt/init.gdb app"],
+ ["gdb app", "gdb app"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes Ninja build-file operands", async () => {
+ for (const [command, expected] of [
+ [`ninja -f ${muxRoot}/skills/launch.txt leak`, REDACTED_BACKUP_VALUE],
+ [`ninja -f${muxRoot}/skills/launch.txt leak`, REDACTED_BACKUP_VALUE],
+ [`ninja-build -f ${muxRoot}/skills/launch.txt leak`, REDACTED_BACKUP_VALUE],
+ // A foreign build file and a plain invocation stay portable.
+ ["ninja -f /opt/build.ninja leak", "ninja -f /opt/build.ninja leak"],
+ ["ninja leak", "ninja leak"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes LLDB source and one-line command options", async () => {
+ for (const [command, expected] of [
+ [`lldb -s ${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`lldb -S${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`lldb --source=${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`lldb --source-before-file ${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ [`lldb -K ${muxRoot}/skills/launch.txt app`, REDACTED_BACKUP_VALUE],
+ // One-line options execute LLDB commands directly.
+ ["lldb -o run app", REDACTED_BACKUP_VALUE],
+ ["lldb --one-line-before-file=run app", REDACTED_BACKUP_VALUE],
+ // A foreign source file and a plain invocation stay portable.
+ ["lldb -s /opt/init.lldb app", "lldb -s /opt/init.lldb app"],
+ ["lldb app", "lldb app"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes tar compression-program operands", async () => {
+ for (const [command, expected] of [
+ [`tar -I ${muxRoot}/skills/launch.txt -cf out.tar input`, REDACTED_BACKUP_VALUE],
+ [`tar -I${muxRoot}/skills/launch.txt -cf out.tar input`, REDACTED_BACKUP_VALUE],
+ [
+ `gtar --use-compress-program=${muxRoot}/skills/launch.txt -cf out.tar input`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "tar --use-compress-program=/usr/bin/gzip -cf out.tar input",
+ "tar --use-compress-program=/usr/bin/gzip -cf out.tar input",
+ ],
+ ["tar -cf out.tar input", "tar -cf out.tar input"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes openssl configuration operands", async () => {
+ for (const [command, expected] of [
+ [`openssl req -config ${muxRoot}/skills/config.txt -new`, REDACTED_BACKUP_VALUE],
+ [`openssl req --config ${muxRoot}/skills/config.txt -new`, REDACTED_BACKUP_VALUE],
+ // A foreign config is not a collected document.
+ [
+ "openssl req -config /etc/ssl/openssl.cnf -new",
+ "openssl req -config /etc/ssl/openssl.cnf -new",
+ ],
+ ["openssl x509 -in cert.pem -noout", "openssl x509 -in cert.pem -noout"],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes openssl launchers under an inherited published OPENSSL_CONF", async () => {
+ const originalConf = process.env.OPENSSL_CONF;
+ try {
+ for (const [conf, command, expected] of [
+ [`${muxRoot}/skills/config.txt`, "openssl req -new", REDACTED_BACKUP_VALUE],
+ // Other launchers do not read OPENSSL_CONF.
+ [
+ `${muxRoot}/skills/config.txt`,
+ "mcp-server --transport stdio",
+ "mcp-server --transport stdio",
+ ],
+ // A foreign config is not a collected document.
+ ["/etc/ssl/openssl.cnf", "openssl req -new", "openssl req -new"],
+ ] as const) {
+ process.env.OPENSSL_CONF = conf;
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ } finally {
+ if (originalConf === undefined) delete process.env.OPENSSL_CONF;
+ else process.env.OPENSSL_CONF = originalConf;
+ }
+ });
+
+ it("localizes Git remote helper program operands", async () => {
+ for (const [command, expected] of [
+ [`git fetch --upload-pack ${muxRoot}/skills/launch.txt origin`, REDACTED_BACKUP_VALUE],
+ [`git clone --upload-pack=${muxRoot}/skills/launch.txt repo`, REDACTED_BACKUP_VALUE],
+ [`git clone -u ${muxRoot}/skills/launch.txt repo`, REDACTED_BACKUP_VALUE],
+ [`git push --receive-pack ${muxRoot}/skills/launch.txt origin`, REDACTED_BACKUP_VALUE],
+ [`git push --exec=${muxRoot}/skills/launch.txt origin`, REDACTED_BACKUP_VALUE],
+ [
+ "git fetch --upload-pack /usr/bin/git-upload-pack origin",
+ "git fetch --upload-pack /usr/bin/git-upload-pack origin",
+ ],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes command-valued git -c and --config-env overrides", async () => {
+ for (const [command, expected] of [
+ // The assignment redaction replaces an unquoted `-c` value before the
+ // scan, so each sensitive key class fails closed on its hidden value.
+ [
+ `git -c core.sshCommand=${muxRoot}/skills/launch.txt ls-remote ssh://example.invalid/repo`,
+ REDACTED_BACKUP_VALUE,
+ ],
+ ["git -c alias.up=!probe fetch", REDACTED_BACKUP_VALUE],
+ ["git -c core.fsmonitor=true status", REDACTED_BACKUP_VALUE],
+ ["git -c include.path=/etc/gitconfig status", REDACTED_BACKUP_VALUE],
+ // The env-valued spelling reads a value this scan cannot see, so a
+ // sensitive key fails closed in both attached and separate forms.
+ [
+ "git --config-env=core.sshCommand=SSH_HELPER ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ [
+ "git --config-env core.sshCommand=SSH_HELPER ls-remote ssh://example.invalid/repo",
+ REDACTED_BACKUP_VALUE,
+ ],
+ // A data key stays portable, keeping only its value's assignment marker.
+ ["git -c user.name=xum log", `git -c user.name=${REDACTED_BACKUP_VALUE} log`],
+ // A valueless data-key override sets the boolean true, never a command.
+ ["git -c color.ui status", "git -c color.ui status"],
+ // A valueless sensitive key still fails closed.
+ ["git -c core.sshCommand status", REDACTED_BACKUP_VALUE],
+ ] as const) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(expected);
+ }
+ });
+
+ it("localizes the pre-rename spelling of a renamed settings root", async () => {
+ const xumRoot = path.join(tempDir, ".xum");
+ await fs.mkdir(xumRoot);
+ await writeFixtureFile(
+ xumRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { private: { command: `node ${tempDir}/.mux/agents/launch.md` } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot: xumRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("keeps boolean core.fsmonitor configuration while localizing hook pathnames", async () => {
+ for (const command of [
+ "git config core.fsmonitor false && mcp-server",
+ "git config core.fsmonitor true && mcp-server",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(command);
+ }
+
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { private: { command: "git config core.fsmonitor /usr/local/bin/watch-hook" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes Git config includes of published documents", async () => {
+ for (const command of [
+ `git config include.path ${muxRoot}/skills/launch.txt && git x`,
+ `git config includeif.gitdir:/w/.path ${muxRoot}/AGENTS.md`,
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+
+ // Includes of files this backup does not publish stay portable.
+ const portable = "git config include.path /tmp/extra.gitconfig && git x";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command: portable } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(portable);
+ });
+
+ // resolves to the collected settings root inside each test, so every entry
+ // also covers a custom (XUM_ROOT-style) root the old segment matching missed.
+ for (const [name, command] of [
+ ["Python", "python3 /skills/launch.txt"],
+ // `--` ends option parsing but the next positional is still the script operand.
+ ["Python after option terminator", "python3 -- /skills/launch.txt"],
+ ["Node", "node /agents/launch.md"],
+ ["Rscript", "Rscript /memory/global/launch.markdown"],
+ ["Ruby separate working directory", "ruby -C skills/launch.txt"],
+ ["Ruby attached working directory", "ruby -C agents/launch.md"],
+ ["Ruby working directory before --", "ruby -C -- skills/launch.txt"],
+ ["Lua", "lua5.4 /skills/launch.txt"],
+ ["LuaJIT", "luajit /agents/launch.md"],
+ ["Swift", "swift /skills/launch.txt"],
+ ["Elixir", "elixir /agents/launch.md"],
+ ["Erlang escript", "escript /skills/launch.txt"],
+ ["Java source mode", "java --source 17 /skills/launch.txt"],
+ ["Java attached source mode", "java --source=17 /agents/launch.md"],
+ [
+ "Java source mode with option values",
+ "java --class-path libs --source 17 --module-path mods /skills/launch.txt",
+ ],
+ // -jar executes the archive operand regardless of its filename extension.
+ ["Java jar", "java -jar /skills/launch.txt"],
+ ["Java jar (javaw)", "javaw -jar /agents/launch.md"],
+ ["Java class path", "java -cp /skills/launch.txt Leak"],
+ ["Java long class path", "java --class-path=/agents/launch.md Leak"],
+ ["JShell", "jshell /skills/launch.txt"],
+ ["JShell attached startup file", "jshell --startup=/skills/launch.txt"],
+ ["JShell separate startup file", "jshell --startup /skills/launch.txt"],
+ ["Tcl", "tclsh /skills/launch.txt"],
+ ["Tk wish", "wish8.6 /agents/launch.md"],
+ ["Expect", "expect /memory/global/launch.txt"],
+ ["R attached file option", "R --file=/skills/launch.txt"],
+ ["R separate file option", "R -f /skills/launch.txt"],
+ ["PHP attached file option", "php --file=/skills/launch.mdx"],
+ ["PHP attached config option", "php -c/skills/config.txt /opt/server.php"],
+ ["PHP separate file option", "php -f /memory/global/launch.markdown"],
+ ["PHP process-file option", "php -F/skills/launch.txt"],
+ ["PHP long process-file option", "php --process-file=/skills/launch.txt"],
+ ["PHP separate process-file option", "php --process-file /skills/launch.txt"],
+ ["Deno bare entrypoint", "deno run /skills/launch.mdx"],
+ // Backslash spelling of the same root, normalized like a Windows path.
+ ["Deno", "deno run --config deno.json '\\skills\\launch.mdx'"],
+ ] as const) {
+ it(`localizes ${name} execution of auto-published documents`, async () => {
+ const resolved = command
+ .replaceAll("", muxRoot)
+ .replaceAll("", muxRoot.replaceAll("/", "\\"));
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command: resolved } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+ }
+
+ it("localizes runtime preload modules", async () => {
+ for (const command of [
+ `node --require ${muxRoot}/skills/launch.txt server.js`,
+ `node -r${muxRoot}/skills/launch.txt server.js`,
+ `bun --preload ${muxRoot}/skills/launch.txt server.ts`,
+ `bun --require=${muxRoot}/skills/launch.txt server.ts`,
+ `bun -r${muxRoot}/skills/launch.txt server.ts`,
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes Git config shell callbacks", async () => {
+ for (const command of [
+ "git config alias.x '!mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'; git x",
+ "git config --global --add alias.launch '!mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ "git config core.sshCommand 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'; git fetch origin",
+ // git gc runs the configured recent-objects hook while pruning cruft.
+ "git config gc.recentObjectsHook /tmp/hook.sh; git gc --cruft --prune=now",
+ // A !-prefixed submodule update value runs in place of the built-in modes.
+ "git config submodule.vendor.update '!mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'; git submodule update",
+ "git config credential.helper '!mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ "git config filter.secret.process 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ "git config merge.leak.driver 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'; git merge side",
+ "git config diff.leak.textconv 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ "git config hook.leak.command 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes git shell callback modes", async () => {
+ for (const command of [
+ "git submodule --quiet foreach 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "git rebase --exec 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' HEAD~1",
+ "git filter-branch --tree-filter 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' -- --all",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes makefile-driven launchers", async () => {
+ for (const command of [
+ `make -f ${muxRoot}/skills/launch.txt`,
+ `gmake --file=${muxRoot}/skills/launch.txt`,
+ "make --eval='run:;mcp --token ghp_Abcdef1234'",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { private: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { private: { command: string } };
+ };
+ expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+ });
+
+ it("localizes commands that write files through active redirection", async () => {
+ // A write redirection lets the command assemble a credential file the scans
+ // cannot model (`printf a >f; printf b >>f`), so any active `>` goes
+ // machine-local; quoted arrows are ordinary argument text.
+ for (const command of [
+ "printf ghp_aaaaaaaaaa >/tmp/token; printf bbbbbbbbbb >>/tmp/token; mcp --token-file /tmp/token",
+ "mcp-server 2>&1",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ }
+
+ const portable = "mcp --arrow '->'";
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: portable } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(portable);
+ });
+
+ it("localizes pipelines and shell-built environment credentials", async () => {
+ // A pipe hands one stage's bytes to the next (`read` can turn published
+ // fragments into an exported variable), and `printf -v` plus `export` builds a
+ // credential in the environment with no `=`, `$`, or redirection in sight; both
+ // channels go machine-local.
+ for (const command of [
+ "exec 3<&0; { printf ghp_aaaaaaaaaa; printf bbbbbbbbbb; } | { read -r TOKEN; export TOKEN; mcp <&3; }",
+ "printf ghp_aaaaaaaaaa | mcp-server",
+ "printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; export TOKEN; mcp",
+ // Inherited SHELLOPTS=allexport exports a printf-built value without any
+ // explicit state-changing word in the command. Bash accepts both separated
+ // and attached variable-option spellings.
+ "printf -vTOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
+ "printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
+ "set -a; printf -v TOKEN %s ghp_aaaaaaaaaabbbbbbbbbb; mcp",
+ // Trap actions are reparsed when the signal fires; first-parse quotes can hide
+ // the expansion and escape that join the token at EXIT.
+ "trap 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' EXIT",
+ // `mapfile -C` evaluates its callback text as a command when lines are read;
+ // a plain `<` read redirection is otherwise portable.
+ "mapfile -C 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789;:' -c 1 {
+ // App user (ghu_), installation (ghs_), and refresh (ghr_) GitHub tokens,
+ // Slack app-level (xapp-) tokens, and Stripe live secret/restricted keys
+ // (sk_live_/rk_live_) are issued-only like ghp_/gho_/xoxb-; a collected
+ // documentation file must not publish any of them.
+ for (const prefix of ["ghu_", "ghs_", "ghr_", "xapp-1-", "sk_live_", "rk_live_", "npm_"]) {
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ `token: ${prefix}K3vQ9rT2wY7bN4mJ6hL8cD1f\n`
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ }
+ });
+
+ it("bounds aggregate command analysis across one config", async () => {
+ // Each command below the per-command cap still costs a per-character walk, so a
+ // near-8MB config of cap-length commands could freeze the main process for
+ // seconds; past the aggregate budget, commands localize without being parsed.
+ const wall = `mcp ${"{".repeat(MAX_ANALYZED_COMMAND_LENGTH - 4)}`;
+ const servers = Object.fromEntries(
+ Array.from({ length: 10 }, (_, index) => [`c${index}`, { command: wall }])
+ );
+ await writeFixtureFile(muxRoot, "mcp.jsonc", JSON.stringify({ servers }));
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: Record;
+ };
+ // The first commands fit the budget and publish; the rest go machine-local.
+ expect(mcp.servers.c0?.command).toBe(wall);
+ expect(mcp.servers.c8?.command).toBe(REDACTED_BACKUP_VALUE);
+ expect(mcp.servers.c9?.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("localizes an oversized command without parsing it", async () => {
+ // The per-character walks hold state proportional to command length, so an
+ // adversarial brace wall must go machine-local before any analysis allocates.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: `mcp ${"{".repeat(40000)}` } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ });
+
+ it("blocks temporary AWS access-key IDs without an override", async () => {
+ // Temporary credentials use ASIA rather than the long-term AKIA prefix; the
+ // accompanying secret and session token have no dependable issued prefix.
+ const accessKeyId = ["ASIA", "1234567890ABCDEF"].join("");
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", `AWS_ACCESS_KEY_ID=${accessKeyId}\n`);
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ });
+
+ it("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ "Use AKIAIOSFODNN7EXAMPLE as the access key in examples\n"
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ // The reviewable scan still lists the file for the digest approval flow.
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual(["skills/demo/SKILL.md"]);
+ });
+
+ it("keeps digit-free sk- placeholders reviewable instead of hard-blocking", async () => {
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", "Use sk-your-api-key-here to start\n");
+ // The reviewable scan still flags it, so the digest approval path stays intact.
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual(["skills/demo/SKILL.md"]);
+
+ // A digit-bearing key of the same shape still aborts with no override.
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", "sk-a1b2c3d4e5f6g7h8i9j0\n");
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
+ it("keeps digit-free Slack token placeholders reviewable instead of hard-blocking", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ "Use xoxb-your-token-here to connect\n"
+ );
+ // The reviewable scan still flags it, so the digest approval path stays intact.
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual(["skills/demo/SKILL.md"]);
+
+ // A digit-bearing token of the same shape still aborts with no override.
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", "xoxb-12345abcde\n");
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
+ it("classifies a size-limit document that is one wall of token candidates", async () => {
+ // Walls of in-class candidates previously exhausted V8's regexp backtrack stack
+ // (RangeError) before the scan could return a classification at all.
+ const wall = "glsa_".repeat(Math.floor(MAX_BACKUP_FILE_BYTES / 5));
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", wall);
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ });
+
+ it("flags any private-key PEM label for review", async () => {
+ for (const label of [
+ "-----BEGIN ENCRYPTED PRIVATE KEY-----",
+ "-----BEGIN DSA PRIVATE KEY-----",
+ "-----BEGIN PGP PRIVATE KEY BLOCK-----",
]) {
- await writeFixtureFile(muxRoot, secretFile, "must not export\n");
+ await writeFixtureFile(muxRoot, "skills/notes.md", `example key material\n${label}\n`);
+ // Reviewable only: documentation quoting key blocks stays overridable.
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual(["skills/notes.md"]);
}
+ });
+ it("keeps a digit-free sk- wall reviewable at the size limit", async () => {
+ const wall = "sk-".repeat(Math.floor(MAX_BACKUP_FILE_BYTES / 3));
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", wall);
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual(["skills/demo/SKILL.md"]);
+ });
+
+ it("does not manufacture credentials from quote-separated documentation text", async () => {
+ // Only command content is shell input; prose keeps its bytes as written.
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", 'ghp_aaaaaaaaaa"bbbbbbbbbb\n');
const payload = await createBackupPayload({
muxRoot,
muxVersion: "1.2.3",
sourceLabel: "test-host",
- exportedAt: "2026-07-30T00:00:00.000Z",
- preferences: {
- appearance: { theme: "dark", vimEnabled: true },
- navigation: { launchBehavior: "dashboard", projectOrder: ["/private/project"] },
- ai: {
- globalDefaults: { agentId: "exec" },
- projectDefaults: { "/private/project": { model: "secret/model" } },
- autoCompactionThresholdByModel: { "openai/gpt": 75 },
- },
- workspaceCreation: { byProject: { "/private/project": { trunkBranch: "main" } } },
- notifications: { notifyOnResponseByWorkspace: { workspace: true } },
- review: {
- includeUncommitted: true,
- defaultBaseByProject: { "/private/project": "main" },
- },
- },
});
+ expect(payload.files.some((file) => file.path === "skills/demo/SKILL.md")).toBe(true);
+ });
- expect(payload.files.map((file) => file.path)).toEqual([
- "AGENTS.md",
- "agents/reviewer.md",
- "memory/global/note.md",
- "preferences.json",
- "skills/review/SKILL.md",
- ]);
- expect(payload.manifest.files.map((file) => file.path)).toEqual(
- payload.files.map((file) => file.path)
+ it("blocks the export when a UTF-16 document carries a credential token", async () => {
+ const token = "ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8";
+ await fs.mkdir(path.join(muxRoot, "skills", "demo"), { recursive: true });
+ await fs.writeFile(
+ path.join(muxRoot, "skills", "demo", "SKILL.md"),
+ Buffer.from(`docs with ${token}\n`, "utf16le")
);
- const preferences = JSON.parse(payloadFileText(payload, "preferences.json")) as Record<
- string,
- unknown
- >;
- expect(preferences).toEqual({
- appearance: { theme: "dark", vimEnabled: true },
- navigation: { launchBehavior: "dashboard" },
- ai: {
- globalDefaults: { agentId: "exec" },
- autoCompactionThresholdByModel: { "openai/gpt": 75 },
- },
- review: { includeUncommitted: true },
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ });
+
+ it("blocks the export when a credential format appears in a published path", async () => {
+ const token = "ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8";
+ await writeFixtureFile(muxRoot, `skills/${token}/SKILL.md`, "docs only\n");
+
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ expect((blocked as BackupCredentialDetectedError).files).toEqual([`skills/${token}/SKILL.md`]);
+
+ const snapshot = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ keepLocalSecrets: true,
+ reportSecrets: true,
});
+ expect(snapshot.files.some((file) => file.path.includes(token))).toBe(true);
});
- it("keeps MCP commands and URLs while redacting literal header values", async () => {
+ it("redacts identically when the settings root is a legacy .mux directory", async () => {
+ const legacyRoot = path.join(tempDir, ".mux");
+ await fs.mkdir(legacyRoot);
await writeFixtureFile(
- muxRoot,
+ legacyRoot,
"mcp.jsonc",
- `{
- // Deploy token: commentsecret
- "servers": {
- "api": {
- "url": "https://user:password@example.com/mcp?token=literal&clientSecret=camel2&X-Amz-Signature=deadbeefcafe&mode=fast",
- "headers": {
- "Authorization": "Bearer literal",
- "Secret": { "secret": "MCP_SECRET" }
- }
- },
- "plain": {
- "url": "https://example.com/mcp?mode=fast"
- },
- "objectCommand": { "command": "npx object-mcp --root /workspace" },
- "bareCommand": "bare-mcp --verbose"
- }
-}
-`
+ JSON.stringify({ servers: { grafana: { command: "TOKEN=hunter2 mcp-grafana" } } })
);
const payload = await createBackupPayload({
- muxRoot,
+ muxRoot: legacyRoot,
muxVersion: "1.2.3",
- sourceLabel: "test-host",
+ sourceLabel: ".mux",
reportSecrets: true,
});
const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
- servers: {
- api: { url: string; headers: Record };
- plain: { url: string };
- objectCommand: { command: string };
- bareCommand: string;
- };
+ servers: { grafana: { command: string } };
};
-
- expect(mcp.servers.api.headers.Authorization).toBe(REDACTED_BACKUP_VALUE);
- expect(mcp.servers.api.headers.Secret).toEqual({ secret: "MCP_SECRET" });
- expect(mcp.servers.api.url).toBe(
- "https://user:password@example.com/mcp?token=literal&clientSecret=camel2&X-Amz-Signature=deadbeefcafe&mode=fast"
- );
- expect(mcp.servers.plain.url).toBe("https://example.com/mcp?mode=fast");
- expect(mcp.servers.objectCommand.command).toBe("npx object-mcp --root /workspace");
- expect(mcp.servers.bareCommand).toBe("bare-mcp --verbose");
- const text = payloadFileText(payload, "mcp.jsonc");
- expect(text).not.toContain("commentsecret");
- const destination = path.join(tempDir, "redacted-payload");
- await writeBackupPayload(destination, payload);
- expect((await readBackupPayload(destination)).redactions).toEqual(payload.redactions);
- expect(payload.redactions).toEqual(["servers.api.headers.Authorization"]);
+ expect(mcp.servers.grafana.command).toBe(`TOKEN=${REDACTED_BACKUP_VALUE} mcp-grafana`);
+ expect(payload.manifest.mcpRedactions).toEqual([["servers", "grafana", "command"]]);
+ expect(payload.manifest.sourceLabel).toBe(".mux");
});
it("does not create manifests above the MCP redaction limit", async () => {
@@ -833,22 +4770,19 @@ describe("backup payload", () => {
it("refuses to publish generated content that exceeds the limits", async () => {
await writeFixtureFile(muxRoot, "AGENTS.md", "small\n");
- const payload = await createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- preferences: {
- appearance: {
- terminalFontConfig: { fontFamily: "x".repeat(MAX_BACKUP_FILE_BYTES), fontSize: 12 },
- },
- },
- });
-
- // Collection budgets bound what is read, and preferences are generated after it, so a
- // published payload has to be checked once it is assembled.
const oversized = await captureRejection(
- writeBackupPayload(path.join(tempDir, "generated-payload"), payload)
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ preferences: {
+ appearance: {
+ terminalFontConfig: { fontFamily: "x".repeat(MAX_BACKUP_FILE_BYTES), fontSize: 12 },
+ },
+ },
+ })
);
+
expect((oversized as Error).message).toContain("'preferences.json' is larger");
});
@@ -1115,6 +5049,59 @@ describe("backup payload", () => {
expect(restored.servers.remote.headers).toBeUndefined();
});
+ it("keeps a retained neighbor's comment when restore deletes a final property", async () => {
+ // Deletion spans must take exactly the node and its separator: a hand-authored
+ // backup can attach a comment to the kept command, and dropping the redacted url
+ // on a fresh device must not swallow it.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: "npx grafana-mcp", url: "https://user:hunter2@example.com/mcp" },
+ },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payload.manifest.mcpRedactions).toEqual([["servers", "grafana", "url"]]);
+ const commented = [
+ "{",
+ ' "servers": {',
+ ' "grafana": {',
+ ' "command": "npx grafana-mcp", // command rationale',
+ ` "url": "${REDACTED_BACKUP_VALUE}"`,
+ " }",
+ " }",
+ "}",
+ "",
+ ].join("\n");
+ const crafted = withPayloadFileText(payload, "mcp.jsonc", commented);
+ const fresh = path.join(tempDir, "comment-keeping-root");
+ await fs.mkdir(fresh, { recursive: true });
+ const approvals = await collectMcpCommandApprovals(
+ fresh,
+ crafted.files,
+ crafted.manifest.mcpRedactions
+ );
+ await restoreBackupPayload({
+ muxRoot: fresh,
+ payload: crafted,
+ approvedCommandTokens: approvals.map((approval) => approval.token),
+ });
+ const restoredText = await fs.readFile(path.join(fresh, "mcp.jsonc"), "utf-8");
+ expect(restoredText).toContain("// command rationale");
+ const restored = jsonc.parse(restoredText) as {
+ servers: { grafana: { command: string; url?: string } };
+ };
+ expect(restored.servers.grafana.command).toBe("npx grafana-mcp");
+ expect(restored.servers.grafana.url).toBeUndefined();
+ });
+
it("round-trips literal redaction-marker MCP commands", async () => {
for (const [index, server] of [
REDACTED_BACKUP_VALUE,
@@ -1260,6 +5247,49 @@ describe("backup payload", () => {
expect(text).not.toContain("LOCAL_KEY");
});
+ it("keeps crafted control-character server names from shadowing resolved paths", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { safe: { url: "https://user:hunter2@example.com/mcp" } } })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ });
+ const destination = path.join(tempDir, "crafted-name");
+ await writeBackupPayload(destination, payload);
+ const readBack = await readBackupPayload(destination);
+
+ // A repository writer adds a server whose name NUL-joins to the resolved `safe.url`
+ // path, carrying a header reference that would resolve a local secret at its url.
+ const file = readBack.files.find((candidate) => candidate.path === "mcp.jsonc");
+ if (!file) throw new Error("expected mcp.jsonc in the payload");
+ const parsed = jsonc.parse(file.content.toString("utf-8")) as {
+ servers: Record;
+ };
+ parsed.servers["safe\u0000url"] = {
+ url: "https://evil.example/mcp",
+ headers: { Authorization: { secret: "KEY" } },
+ };
+ const tampered = {
+ ...readBack,
+ files: readBack.files.map((candidate) =>
+ candidate.path === "mcp.jsonc"
+ ? { ...candidate, content: Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf-8") }
+ : candidate
+ ),
+ };
+
+ await restoreBackupPayload({ muxRoot, payload: tampered });
+ const restored = jsonc.parse(await fs.readFile(path.join(muxRoot, "mcp.jsonc"), "utf-8")) as {
+ servers: Record }>;
+ };
+ expect(restored.servers.safe.url).toBe("https://user:hunter2@example.com/mcp");
+ expect(restored.servers["safe\u0000url"]).toEqual({ url: "https://evil.example/mcp" });
+ });
+
it("drops a header reference the backup adds, with or without any redaction marker", async () => {
// No marker anywhere in this payload, so nothing signals that it needs inspecting. The
// reference still resolves against local project secrets, and the url is the backup's.
@@ -2065,6 +6095,78 @@ describe("backup payload", () => {
).toBeInstanceOf(BackupCommandApprovalRequiredError);
});
+ it("binds command approval to the exact planned MCP bytes", async () => {
+ // A concurrent editor can rewrite the local mcp.jsonc between restore's reads. If
+ // approval and planning resolve the file separately, the first resolution can
+ // rehydrate a redacted url (shadowing the backup's command, exempting it from
+ // approval) while the second sees the url gone and writes the command runnable.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ '{ "servers": { "evil": { "command": "npx notes-mcp --root /data", "url": "https://mcp.example.com/mcp?api_key=hunter2" } } }\n'
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payload.redactions).toEqual(["servers.evil.url"]);
+ const destination = path.join(tempDir, "toctou-approval");
+ await writeBackupPayload(destination, payload);
+ const readBack = await readBackupPayload(destination);
+
+ const withUrl = '{ "servers": { "evil": { "url": "https://mcp.example.com/mcp" } } }\n';
+ await writeFixtureFile(muxRoot, "mcp.jsonc", withUrl);
+
+ // The editor removes the url right after the first marker resolution has observed
+ // it: opens 1 (restore's local file listing) and 2 (the first resolution) see the
+ // url; the file is rewritten before open 3.
+ const realOpen = fs.open;
+ let localMcpOpens = 0;
+ const openSpy = spyOn(fs, "open").mockImplementation(async (target, flags, mode) => {
+ if (
+ typeof target === "string" &&
+ target.endsWith("mcp.jsonc") &&
+ !target.includes("toctou-approval")
+ ) {
+ localMcpOpens += 1;
+ if (localMcpOpens === 3) {
+ openSpy.mockRestore();
+ await fs.writeFile(target, '{ "servers": {} }\n', "utf-8");
+ return fs.open(target, flags, mode);
+ }
+ }
+ return realOpen.call(fs, target, flags, mode);
+ });
+ try {
+ let approvalError: unknown = null;
+ try {
+ await restoreBackupPayload({ muxRoot, payload: readBack });
+ } catch (error) {
+ approvalError = error;
+ }
+ // Whatever interleaving restore observed, the repository-controlled command must
+ // not become runnable without approval: either restore demanded approval, or the
+ // written entry still carries a url shadowing the command (or lost the server).
+ if (approvalError === null) {
+ const written = jsonc.parse(
+ await fs.readFile(path.join(muxRoot, "mcp.jsonc"), "utf-8")
+ ) as {
+ servers?: Record;
+ };
+ const entry = written.servers?.evil;
+ if (entry?.command !== undefined) {
+ expect(typeof entry.url === "string" && entry.url !== "").toBe(true);
+ }
+ } else {
+ expect(approvalError).toBeInstanceOf(BackupCommandApprovalRequiredError);
+ }
+ } finally {
+ openSpy.mockRestore();
+ }
+ });
+
it("needs no approval to disable a command or for an empty one", async () => {
await writeFixtureFile(
muxRoot,
@@ -2444,7 +6546,7 @@ describe("backup payload", () => {
}
});
- it("gates credential-bearing MCP URLs without rewriting them", async () => {
+ it("redacts credential-bearing MCP URLs whole-value", async () => {
const urls = [
"https://user:hunter2@example.com/mcp",
"https:token@example.com/mcp",
@@ -2452,8 +6554,19 @@ describe("backup payload", () => {
"https:\\token@example.com\\mcp",
"https://mcp.example.com/mcp?api_key=hunter2",
"https://mcp.example.com/mcp?clientSecret=abc",
+ "https://mcp.example.com/mcp?apiToken=hunter2",
+ "https://mcp.example.com/mcp?x-api-key=hunter2",
+ "https://mcp.example.com/mcp?X-Auth-Token=hunter2",
+ "https://mcp.example.com/mcp?private_token=hunter2",
+ "https://mcp.example.com/mcp?sessionToken=hunter2",
+ "https://mcp.example.com/mcp?Ocp-Apim-Subscription-Key=hunter2",
+ "https://mcp.example.com/mcp?subscription_key=hunter2",
"https://mcp.example.com/mcp?code=review",
"https://mcp.example.com/mcp?X-Amz-Signature=deadbeef",
+ // Provider-prefixed signed-URL families qualify the credential word
+ // (X-Goog-Signature, x-oss-credential); one stripped leading x cannot reach them.
+ "https://storage.googleapis.com/bucket/backup?X-Goog-Algorithm=GOOG4-RSA-SHA256&X-Goog-Credential=svc%40proj.iam.gserviceaccount.com%2F20260827%2Fauto%2Fstorage%2Fgoog4_request&X-Goog-Signature=deadbeefcafe0123",
+ "https://oss.example.com/mcp?x-oss-security-token=hunter2",
"https://mcp.example.com/callback?code=oauth-code",
"https://mcp.example.com/mcp#access_token=fragtoken",
"https://mcp.example.com/mcp#callback?api_key=fragment-secret",
@@ -2473,27 +6586,18 @@ describe("backup payload", () => {
"mcp.jsonc",
JSON.stringify({ servers: { private: { url } } })
);
- const blocked = await captureRejection(
- createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- })
- );
- expect((blocked as Error).message).toContain("mcp.jsonc");
-
+ // No reportSecrets: with the credential redacted there is nothing left to approve.
const payload = await createBackupPayload({
muxRoot,
muxVersion: "1.2.3",
sourceLabel: "test-host",
- reportSecrets: true,
});
const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
servers: { private: { url: string } };
};
- expect(exported.servers.private.url).toBe(url);
- expect(scanBackupFilesForSecrets(payload.files)).toEqual(["mcp.jsonc"]);
- expect(payload.redactions).toEqual([]);
+ expect(exported.servers.private.url).toBe(REDACTED_BACKUP_VALUE);
+ expect(scanBackupFilesForSecrets(payload.files)).toEqual([]);
+ expect(payload.redactions).toEqual(["servers.private.url"]);
}
});
@@ -2536,7 +6640,7 @@ describe("backup payload", () => {
JSON.stringify({
servers: {
safe: {
- url: "https://mcp.example.com/mcp?mode=fast&tenant=acme&client_id=public&monkey=banana",
+ url: "https://mcp.example.com/mcp?mode=fast&tenant=acme&client_id=public&monkey=banana&verify_signature=false",
},
unusual: { url: "not a url without parameters" },
email: { url: "mailto:user@example.com" },
@@ -2551,6 +6655,13 @@ describe("backup payload", () => {
sourceLabel: "test-host",
});
expect(scanBackupFilesForSecrets(payload.files)).toEqual([]);
+ // The ordinary parameters must also survive redaction: a false credential match
+ // here removes the server outright on a fresh-device restore.
+ const exported = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
+ servers: { safe: { url: string } };
+ };
+ expect(exported.servers.safe.url).toContain("verify_signature=false");
+ expect(payload.redactions).toEqual([]);
});
it("charges what a restore writes, not only what it read", async () => {
@@ -3317,6 +7428,22 @@ describe("backup payload", () => {
}
});
+ it("blocks URL-encoded high-confidence secrets in published documentation", async () => {
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ "https://example.test/?access_token=ghp%5fAbcdef1234567890KlmnoPqrst987654\n"
+ );
+
+ try {
+ await createBackupPayload({ muxRoot, muxVersion: "1.2.3", sourceLabel: "test-host" });
+ throw new Error("Expected encoded secret scan rejection");
+ } catch (error) {
+ if (!(error instanceof Error)) throw error;
+ expect(error.message).toContain("skills/demo/SKILL.md");
+ }
+ });
+
it("keeps a restored MCP config owner-only", async () => {
if (process.platform === "win32") return;
await writeFixtureFile(
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 076ebc56c9..ddc8d39f18 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1,6 +1,7 @@
import { createHash } from "node:crypto";
-import type { Dirent, Stats } from "node:fs";
+import { realpathSync, type Dirent, type Stats } from "node:fs";
import * as fs from "node:fs/promises";
+import * as os from "node:os";
import * as path from "node:path";
import * as jsonc from "jsonc-parser";
import {
@@ -67,15 +68,267 @@ function isForbiddenBasename(name: string): boolean {
function isHiddenName(name: string): boolean {
return name.startsWith(".");
}
-const SECRET_PATTERNS = [
- /\bsk-[A-Za-z0-9_-]{16,}\b/,
- /\bghp_[A-Za-z0-9]{20,}\b/,
- /\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
- /\bAKIA[0-9A-Z]{16}\b/,
- /\bAIza[A-Za-z0-9_-]{35,}/,
- /\bxoxb-[A-Za-z0-9-]{10,}\b/,
- /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
-] as const;
+/** Suffix alphabets of the issued-token shapes; `word` is exactly the regexp `\w` set. */
+type TokenRunClass = "alnum" | "word" | "alnum-dash" | "word-dash";
+
+/**
+ * One `\b[]{minRun,}\b` token format, matched by hasIssuedToken's
+ * linear scan rather than that regexp: V8 grows its backtrack stack per unbounded
+ * quantifier iteration, so a size-capped file that is one in-class wall
+ * (`glsa_glsa_...`) exhausts it with a RangeError before the scan can classify
+ * anything. Bounded quantifiers and literal alternations stay regexps below.
+ */
+interface IssuedTokenShape {
+ /** Literal case-sensitive spellings that start every candidate; each begins with a word char. */
+ prefixes: readonly string[];
+ runClass: TokenRunClass;
+ minRun: number;
+ /**
+ * Hard-block only digit-bearing bodies: issued keys embed digits practically always
+ * (base62 randomness, Slack's numeric workspace and app IDs), while documentation
+ * placeholders (`sk-your-api-key-here`, `xoxb-your-token-here`) are dash-separated
+ * words. The digit-free spelling stays in the reviewable scan, which ignores this flag.
+ */
+ hardBlockRequiresDigit?: boolean;
+ /** No trailing word boundary: any long-enough body matches even mid-word. */
+ openEnded?: boolean;
+}
+
+/**
+ * Formats issued only as live credentials. A match aborts the export outright, with no
+ * user override: redaction is the primary mechanism, so a surviving match means either a
+ * shape redaction does not classify (a token passed as a command argument) or a redaction
+ * defect, and neither is something a backup should publish.
+ */
+const ISSUED_TOKEN_SHAPES: readonly IssuedTokenShape[] = [
+ // GitHub issued prefixes: personal, OAuth, App user, installation, refresh tokens.
+ { prefixes: ["gho_", "ghp_", "ghu_", "ghs_", "ghr_"], runClass: "alnum", minRun: 20 },
+ { prefixes: ["github_pat_"], runClass: "word", minRun: 20 },
+ { prefixes: ["glsa_"], runClass: "word", minRun: 20 },
+ {
+ // GitLab issued prefixes: personal, deploy, runner, service-account, trigger,
+ // CI job, OAuth app, feature-flag, incoming-mail, and cluster-agent tokens.
+ prefixes: [
+ "glpat-",
+ "gldt-",
+ "glrt-",
+ "glsoat-",
+ "glptt-",
+ "glcbt-",
+ "gloas-",
+ "glffct-",
+ "glimt-",
+ "glagent-",
+ ],
+ runClass: "word-dash",
+ minRun: 20,
+ },
+ { prefixes: ["lin_api_"], runClass: "alnum", minRun: 16 },
+ { prefixes: ["ntn_"], runClass: "alnum", minRun: 16 },
+ // Slack workspace (xox?-) and app-level (xapp-) issued tokens.
+ {
+ prefixes: ["xoxb-", "xoxa-", "xoxp-", "xoxr-", "xoxs-", "xapp-"],
+ runClass: "alnum-dash",
+ minRun: 10,
+ hardBlockRequiresDigit: true,
+ },
+ // Stripe live secret and restricted keys. Test-mode keys stay reviewable:
+ // documentation routinely quotes them, and the block has no override.
+ { prefixes: ["sk_live_", "rk_live_"], runClass: "alnum", minRun: 16 },
+ // npm issued access tokens.
+ { prefixes: ["npm_"], runClass: "alnum", minRun: 24 },
+ { prefixes: ["sk-"], runClass: "word-dash", minRun: 16, hardBlockRequiresDigit: true },
+];
+
+// AWS long-term (AKIA) and temporary-session (ASIA) access-key IDs. The exact {16}
+// count leaves the quantifier no choice points, so the regexp form cannot backtrack.
+const AWS_ACCESS_KEY_ID_PATTERN = /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/;
+
+/** Key formats that documentation legitimately quotes: reviewable, never hard-blocked. */
+const REVIEW_ONLY_TOKEN_SHAPES: readonly IssuedTokenShape[] = [
+ { prefixes: ["AIza"], runClass: "word-dash", minRun: 35, openEnded: true },
+];
+
+// Any qualifier before PRIVATE KEY counts: OpenSSL emits ENCRYPTED/DSA qualifiers and
+// PGP armors a BLOCK suffix, and a prose false positive only flags a file for review.
+const PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY(?: BLOCK)?-----/;
+
+function isAsciiDigitCode(code: number): boolean {
+ return code >= 48 && code <= 57;
+}
+
+/** Exactly the alphabet the regexp `\b` assertion evaluates. */
+function isWordCode(code: number): boolean {
+ return (
+ isAsciiDigitCode(code) ||
+ (code >= 65 && code <= 90) ||
+ (code >= 97 && code <= 122) ||
+ code === 95
+ );
+}
+
+function isRunCode(code: number, runClass: TokenRunClass): boolean {
+ if (code === 95) return runClass === "word" || runClass === "word-dash";
+ if (code === 45) return runClass === "alnum-dash" || runClass === "word-dash";
+ return isAsciiDigitCode(code) || (code >= 65 && code <= 90) || (code >= 97 && code <= 122);
+}
+
+function rangeHasDigit(text: string, start: number, end: number): boolean {
+ for (let pos = start; pos < end; pos += 1) {
+ if (isAsciiDigitCode(text.charCodeAt(pos))) return true;
+ }
+ return false;
+}
+
+/**
+ * Linear-time equivalent of the shape's regexp. Each candidate extends its maximal
+ * in-class run once; when no trailing boundary satisfies the run, later candidates
+ * inside the same run are skipped, because they would need a boundary even further
+ * right than the ones that already failed. The trailing boundary walks backward from
+ * the run end so the digit check sees the same greedy span the regexp would match,
+ * and a digit-free match resumes at its end exactly like a matchAll iteration.
+ */
+function hasIssuedToken(text: string, shape: IssuedTokenShape, requireDigit: boolean): boolean {
+ for (const prefix of shape.prefixes) {
+ let from = 0;
+ let idx = text.indexOf(prefix, from);
+ while (idx !== -1) {
+ if (idx > 0 && isWordCode(text.charCodeAt(idx - 1))) {
+ // No word boundary before the prefix. A candidate hidden inside a run this
+ // scan skips below is always in this case: run alphabets contain only word
+ // characters and `-`, and a `-` before a skipped candidate implies a
+ // boundary the failed enclosing candidate would have matched first.
+ from = idx + 1;
+ } else {
+ const runStart = idx + prefix.length;
+ let runEnd = runStart;
+ while (runEnd < text.length && isRunCode(text.charCodeAt(runEnd), shape.runClass)) {
+ runEnd += 1;
+ }
+ const shortestEnd = runStart + shape.minRun;
+ if (runEnd < shortestEnd) {
+ from = runEnd;
+ } else {
+ let matchEnd = -1;
+ if (shape.openEnded === true) {
+ matchEnd = runEnd;
+ } else {
+ for (let pos = runEnd; pos >= shortestEnd; pos -= 1) {
+ const wordAfter = pos < text.length && isWordCode(text.charCodeAt(pos));
+ if (isWordCode(text.charCodeAt(pos - 1)) !== wordAfter) {
+ matchEnd = pos;
+ break;
+ }
+ }
+ }
+ if (matchEnd === -1) {
+ from = runEnd;
+ } else if (!requireDigit || rangeHasDigit(text, runStart, matchEnd)) {
+ return true;
+ } else {
+ from = matchEnd;
+ }
+ }
+ }
+ idx = text.indexOf(prefix, from);
+ }
+ }
+ return false;
+}
+
+/**
+ * AWS's documented example access key is valid-shape but never a live credential;
+ * documentation quoting it stays in the reviewable scan instead of the no-override
+ * block. Replaced with a space so removal cannot splice surrounding text into a match.
+ */
+const EXAMPLE_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
+
+/**
+ * Lazily filled fold-class ids for the non-ASCII comparison path: two units share an
+ * id exactly when their single-unit `toLowerCase` strings are equal, so a run
+ * alternating case-equivalent units (U+212A KELVIN SIGN with `k`) costs one typed
+ * array read per unit instead of two string allocations per comparison across a
+ * size-capped payload. The table is bounded by the UTF-16 alphabet (256 KiB).
+ */
+const FOLD_CLASS_IDS = new Uint32Array(65536);
+const FOLD_CLASS_BY_STRING = new Map();
+
+function foldClassId(code: number): number {
+ let id = FOLD_CLASS_IDS[code];
+ if (id === 0) {
+ const folded = String.fromCharCode(code).toLowerCase();
+ id = FOLD_CLASS_BY_STRING.get(folded) ?? 0;
+ if (id === 0) {
+ id = FOLD_CLASS_BY_STRING.size + 1;
+ FOLD_CLASS_BY_STRING.set(folded, id);
+ }
+ FOLD_CLASS_IDS[code] = id;
+ }
+ return id;
+}
+
+/**
+ * Case-insensitive equality of two UTF-16 units without allocating per-character
+ * lowercase strings, which dominated the synchronous scan of a size-capped payload.
+ * ASCII folds arithmetically; only a non-ASCII unit pays for a cached fold-class
+ * lookup (which also catches case-equivalent units like U+212A KELVIN SIGN and `k`).
+ */
+function sameFoldedUnit(a: number, b: number): boolean {
+ if (a === b) return true;
+ const foldedA = a >= 65 && a <= 90 ? a + 32 : a;
+ const foldedB = b >= 65 && b <= 90 ? b + 32 : b;
+ if (foldedA === foldedB) return true;
+ if (a < 128 && b < 128) return false;
+ return foldClassId(a) === foldClassId(b);
+}
+
+/**
+ * A run of one repeated character (case-insensitive) is documentation spelling, never
+ * issued-token entropy (`ghp_xxxxxxxx...`), so those spellings stay in the reviewable
+ * scan instead of the no-override block. Replaced with a space like the example key,
+ * so the removal cannot splice neighbors into a match, and a real token padded with an
+ * obvious run still matches on what remains. One linear pass rather than
+ * `/(.)\1{15,}/gi`: V8 exhausts its call stack evaluating that backreference across a
+ * multi-megabyte single-character run, rejecting a size-valid file before it is
+ * scanned. Line terminators never join runs, matching the dot the regex used.
+ */
+function stripPlaceholderRuns(text: string): string {
+ let result = "";
+ let keptFrom = 0;
+ let i = 0;
+ while (i < text.length) {
+ const anchor = text.charCodeAt(i);
+ let end = i + 1;
+ if (anchor !== 10 && anchor !== 13 && anchor !== 0x2028 && anchor !== 0x2029) {
+ while (end < text.length && sameFoldedUnit(anchor, text.charCodeAt(end))) end += 1;
+ }
+ if (end - i >= 16) {
+ result += text.slice(keptFrom, i) + " ";
+ keptFrom = end;
+ }
+ i = end;
+ }
+ return keptFrom === 0 ? text : result + text.slice(keptFrom);
+}
+
+function matchesCredentialToken(text: string): boolean {
+ const scannable = stripPlaceholderRuns(text.replaceAll(EXAMPLE_ACCESS_KEY, " "));
+ return (
+ ISSUED_TOKEN_SHAPES.some((shape) =>
+ hasIssuedToken(scannable, shape, shape.hardBlockRequiresDigit === true)
+ ) || AWS_ACCESS_KEY_ID_PATTERN.test(scannable)
+ );
+}
+
+/** The reviewable scan flags every issued shape, digit-bearing or not. */
+function matchesReviewableSecret(content: string): boolean {
+ return (
+ ISSUED_TOKEN_SHAPES.some((shape) => hasIssuedToken(content, shape, false)) ||
+ REVIEW_ONLY_TOKEN_SHAPES.some((shape) => hasIssuedToken(content, shape, false)) ||
+ AWS_ACCESS_KEY_ID_PATTERN.test(content) ||
+ PRIVATE_KEY_PATTERN.test(content)
+ );
+}
export interface BackupFile {
path: string;
@@ -127,6 +380,21 @@ export interface RestoreBackupPayloadOptions {
approvedCommandTokens?: readonly string[];
}
+/**
+ * No secretApproval digest on purpose: unlike the reviewable secret scan, this block has no
+ * user override, so the UI shows it as a hard failure instead of offering approval.
+ */
+export class BackupCredentialDetectedError extends Error {
+ readonly code = "SECRET_DETECTED";
+
+ constructor(readonly files: string[]) {
+ super(
+ `Backup blocked: values matching known credential formats were found in ${files.join(", ")}. Remove the credentials from the local files, then back up again.`
+ );
+ this.name = "BackupCredentialDetectedError";
+ }
+}
+
export class BackupCommandApprovalRequiredError extends Error {
readonly code = "COMMAND_APPROVAL_REQUIRED";
@@ -377,6 +645,10 @@ function createByteBudget() {
type ByteBudget = ReturnType;
+function takeBackupFileBytes(budget: ByteBudget, files: readonly BackupFile[]): void {
+ for (const file of files) budget(file.path, file.content.length);
+}
+
/**
* Two paths collide when the filesystem cannot tell them apart, so the comparison has to fold
* the same things a filesystem does. Case is the obvious one, and macOS also normalizes: NFC
@@ -938,170 +1210,2986 @@ const JSONC_EDIT_OPTIONS: jsonc.ModificationOptions = {
/**
* Rewrites values in place with jsonc edits, leaving the rest of the document as it was.
* Restore needs that: it writes the file the user just previewed, not a reformatted copy.
+ *
+ * One parse for the whole batch: `jsonc.modify` reparses the document on every call, so
+ * per-edit application costs edit-count times document-size synchronous work on inputs
+ * a crafted backup controls (256 valid deletions on a near-limit file take nearly a
+ * minute). Spans are planned against a single tree and spliced in one pass; any batch
+ * the planner cannot place falls back to the sequential behavior it replaces.
+ */
+function applyJsoncEdits(text: string, edits: Array<{ path: jsonc.JSONPath; value: unknown }>) {
+ if (edits.length === 0) return text;
+ const spans = planJsoncEditSpans(text, edits);
+ if (spans === undefined) {
+ let result = text;
+ for (const edit of edits) {
+ result = jsonc.applyEdits(
+ result,
+ jsonc.modify(result, edit.path, edit.value, JSONC_EDIT_OPTIONS)
+ );
+ }
+ return result;
+ }
+ let result = "";
+ let cursor = 0;
+ for (const span of spans) {
+ result += text.slice(cursor, span.offset) + span.content;
+ cursor = span.offset + span.length;
+ }
+ return result + text.slice(cursor);
+}
+
+interface JsoncEditSpan {
+ offset: number;
+ length: number;
+ content: string;
+}
+
+/**
+ * Finds the separator comma inside the trivia between two sibling nodes, skipping
+ * comments whose text may itself contain commas. Undefined when the gap holds anything
+ * other than whitespace, comments, and at most one comma, sending the batch to the
+ * sequential path.
+ */
+function findSeparatorCommaOffset(gapText: string): number | undefined {
+ let i = 0;
+ while (i < gapText.length) {
+ const character = gapText[i] ?? "";
+ if (character === ",") return i;
+ if (character === "/" && gapText[i + 1] === "/") {
+ while (i < gapText.length && gapText[i] !== "\n") i += 1;
+ continue;
+ }
+ if (character === "/" && gapText[i + 1] === "*") {
+ const end = gapText.indexOf("*/", i + 2);
+ if (end < 0) return undefined;
+ i = end + 2;
+ continue;
+ }
+ if (!/\s/.test(character)) return undefined;
+ i += 1;
+ }
+ return undefined;
+}
+
+/**
+ * Plans one text span per replacement plus per-node and per-comma spans for deletions,
+ * all against a single parse. Returns undefined for any batch it cannot place exactly
+ * (a missing node, a segment/container type mismatch, an unrecognizable separator gap,
+ * overlapping spans), handing those to the sequential path instead of guessing.
+ */
+function planJsoncEditSpans(
+ text: string,
+ edits: Array<{ path: jsonc.JSONPath; value: unknown }>
+): JsoncEditSpan[] | undefined {
+ const root = jsonc.parseTree(text);
+ if (root === undefined) return undefined;
+ const spans: JsoncEditSpan[] = [];
+ const deletionsByParent = new Map<
+ string,
+ { parentPath: jsonc.JSONPath; segments: Array }
+ >();
+ for (const edit of edits) {
+ if (edit.value !== undefined) {
+ const node = jsonc.findNodeAtLocation(root, edit.path);
+ if (node === undefined) return undefined;
+ spans.push({ offset: node.offset, length: node.length, content: JSON.stringify(edit.value) });
+ continue;
+ }
+ const segment = edit.path[edit.path.length - 1];
+ if (segment === undefined) return undefined;
+ const parentPath = edit.path.slice(0, -1);
+ const key = JSON.stringify(parentPath);
+ const entry = deletionsByParent.get(key) ?? { parentPath, segments: [] };
+ entry.segments.push(segment);
+ deletionsByParent.set(key, entry);
+ }
+ for (const { parentPath, segments } of deletionsByParent.values()) {
+ const parent = parentPath.length === 0 ? root : jsonc.findNodeAtLocation(root, parentPath);
+ if (parent === undefined || (parent.type !== "object" && parent.type !== "array")) {
+ return undefined;
+ }
+ const children = parent.children ?? [];
+ const deleted = new Set();
+ for (const segment of segments) {
+ let index: number;
+ if (parent.type === "array") {
+ if (typeof segment !== "number") return undefined;
+ index = segment;
+ } else {
+ if (typeof segment !== "string") return undefined;
+ index = children.findIndex((child) => child.children?.[0]?.value === segment);
+ }
+ if (index < 0 || index >= children.length || deleted.has(index)) return undefined;
+ deleted.add(index);
+ }
+ // Exactly the child nodes and their separator commas go; comments and other
+ // trivia in the gaps survive, so a comment attached to a retained neighbor is
+ // not swallowed when the entry after it is deleted.
+ let lastKept = -1;
+ for (let i = 0; i < children.length; i += 1) {
+ if (!deleted.has(i)) lastKept = i;
+ }
+ for (const index of deleted) {
+ const child = children[index];
+ if (child === undefined) return undefined;
+ spans.push({ offset: child.offset, length: child.length, content: "" });
+ }
+ for (let i = 0; i < children.length - 1; i += 1) {
+ // The comma right after a kept child with a later kept sibling still separates
+ // them; every other comma belonged to a deleted entry.
+ if (!deleted.has(i) && i < lastKept) continue;
+ const current = children[i];
+ const next = children[i + 1];
+ if (current === undefined || next === undefined) return undefined;
+ const gapStart = current.offset + current.length;
+ const commaOffset = findSeparatorCommaOffset(text.slice(gapStart, next.offset));
+ if (commaOffset === undefined) return undefined;
+ spans.push({ offset: gapStart + commaOffset, length: 1, content: "" });
+ }
+ const lastChild = children[children.length - 1];
+ if (lastChild !== undefined && deleted.has(children.length - 1)) {
+ // A JSONC trailing comma after a deleted final entry would dangle; it goes too.
+ const gapStart = lastChild.offset + lastChild.length;
+ const gapEnd = parent.offset + parent.length - 1;
+ const commaOffset = findSeparatorCommaOffset(text.slice(gapStart, gapEnd));
+ if (commaOffset !== undefined) {
+ spans.push({ offset: gapStart + commaOffset, length: 1, content: "" });
+ }
+ }
+ }
+ spans.sort((a, b) => a.offset - b.offset);
+ for (let i = 1; i < spans.length; i += 1) {
+ const previous = spans[i - 1];
+ const current = spans[i];
+ if (previous === undefined || current === undefined) return undefined;
+ if (current.offset < previous.offset + previous.length) return undefined;
+ }
+ return spans;
+}
+
+interface JsoncPropertyInsertion {
+ leadingText: string;
+ propertyText: string;
+ trailingCommentText: string;
+}
+
+type LocalMcpServerMerge =
+ | { kind: "none" }
+ | { kind: "replace"; valueText: string }
+ | {
+ kind: "insert";
+ objectPath: jsonc.JSONPath;
+ entries: JsoncPropertyInsertion[];
+ objectTrailingText: string;
+ };
+
+function containsJsoncComma(text: string): boolean {
+ const scanner = jsonc.createScanner(text, false);
+ for (let token = scanner.scan(); token !== jsonc.SyntaxKind.EOF; token = scanner.scan()) {
+ if (token === jsonc.SyntaxKind.CommaToken) return true;
+ }
+ return false;
+}
+
+function lineIndentAt(text: string, offset: number): string {
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
+ const prefix = text.slice(lineStart, offset);
+ return /^[\t ]*$/.test(prefix) ? prefix : "";
+}
+
+function insertJsoncObjectProperties(
+ text: string,
+ jsonPath: jsonc.JSONPath,
+ entries: readonly JsoncPropertyInsertion[],
+ objectTrailingText: string
+): string {
+ if (entries.length === 0) return text;
+ const tree = jsonc.parseTree(text);
+ const objectNode = tree ? jsonc.findNodeAtLocation(tree, jsonPath) : undefined;
+ if (objectNode?.type !== "object") throw new Error("Invalid mcp.jsonc");
+
+ const properties = objectNode.children ?? [];
+ const lastProperty = properties.at(-1);
+ const objectEnd = objectNode.offset + objectNode.length - 1;
+ const trailingComma =
+ lastProperty !== undefined &&
+ containsJsoncComma(text.slice(lastProperty.offset + lastProperty.length, objectEnd));
+ const objectProperty = objectNode.parent?.type === "property" ? objectNode.parent : undefined;
+ const closingIndent = lineIndentAt(text, objectProperty?.offset ?? objectNode.offset);
+ const propertyIndent = `${closingIndent}${" ".repeat(JSONC_FORMATTING_OPTIONS.tabSize ?? 2)}`;
+ const eol = text.includes("\r\n") ? "\r\n" : "\n";
+ const entryText = entries
+ .map((entry, index) => {
+ const leadingText = entry.leadingText || `${eol}${propertyIndent}`;
+ const comma = index < entries.length - 1 || trailingComma ? "," : "";
+ const trailingComment =
+ entry.trailingCommentText === "" ? "" : ` ${entry.trailingCommentText}`;
+ return `${leadingText}${entry.propertyText}${comma}${trailingComment}`;
+ })
+ .join("");
+ const closeLineStart = text.lastIndexOf("\n", objectEnd - 1) + 1;
+ const closePrefix = text.slice(closeLineStart, objectEnd);
+ const insertAtLineStart = /^[\t ]*$/.test(closePrefix);
+ const insertionOffset = insertAtLineStart ? closeLineStart : objectEnd;
+ const insertedContent = `${entryText}${objectTrailingText}`;
+ const insertionText = insertAtLineStart
+ ? `${insertedContent.replace(/^\r?\n/, "")}${eol}`
+ : `${insertedContent.startsWith(eol) ? "" : eol}${insertedContent}${eol}${closingIndent}`;
+
+ let result = jsonc.applyEdits(text, [
+ { offset: insertionOffset, length: 0, content: insertionText },
+ ]);
+ if (lastProperty !== undefined && !trailingComma) {
+ result = jsonc.applyEdits(result, [
+ { offset: lastProperty.offset + lastProperty.length, length: 0, content: "," },
+ ]);
+ }
+ return result;
+}
+
+function replaceJsoncNodeText(text: string, jsonPath: jsonc.JSONPath, valueText: string): string {
+ const tree = jsonc.parseTree(text);
+ const node = tree ? jsonc.findNodeAtLocation(tree, jsonPath) : undefined;
+ if (!node) throw new Error("Invalid mcp.jsonc");
+ return jsonc.applyEdits(text, [{ offset: node.offset, length: node.length, content: valueText }]);
+}
+
+/**
+ * `McpConfigService.readConfigFile` enumerates `servers` with `Object.entries`, so an array or
+ * a string there becomes runnable servers named by index rather than being ignored. A document
+ * like that cannot be projected field by field, so both an export and a restore refuse it
+ * instead of passing a shape the runtime accepts through unexamined.
+ * A falsy value is not this case: the runtime returns no servers at all for it.
+ */
+function isUnsupportedServerMap(value: unknown): boolean {
+ return Boolean(value) && (typeof value !== "object" || Array.isArray(value));
+}
+
+/**
+ * Fields Xum itself reads (`McpConfigService.normalizeEntry`), with the type it reads them as.
+ * Anything else in the document, at any depth, is replaced with the marker: `normalizeEntry`
+ * ignores an unrecognised field such as `env` or `args`, so nobody here can say whether its
+ * value is a credential, and `{ "API_KEY": "hunter2" }` is not something a scanner can catch.
+ * Restore puts the local value back at that exact path, so a field only Xum ignores is not
+ * lost from a machine that already has it.
+ *
+ * `command` and `url` pass the type check but can still carry credentials in-band, so the
+ * projection additionally redacts env-style assignment values in commands and whole urls
+ * with credential components.
+ */
+const PORTABLE_SERVER_FIELDS: Record boolean> = {
+ command: (value) => typeof value === "string",
+ url: (value) => typeof value === "string",
+ transport: (value) =>
+ value === "stdio" || value === "http" || value === "sse" || value === "auto",
+ disabled: (value) => typeof value === "boolean",
+ toolAllowlist: (value) => Array.isArray(value) && value.every((tool) => typeof tool === "string"),
+};
+
+/**
+ * A jsonc edit keeps every comment, and a comment is prose the projection cannot inspect, so a
+ * local `// token=hunter2` beside a server would be published verbatim and the scanner would
+ * not recognise it either. Reserializing publishes only the values this file kept.
+ */
+function serializeProjectedMcp(text: string): {
+ content: Buffer;
+ parsed: Record;
+} {
+ const parsed = readRecord(jsonc.parse(text));
+ if (!parsed) throw new Error("Invalid mcp.jsonc");
+ return {
+ content: Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf-8"),
+ parsed,
+ };
+}
+
+function valueHasRedactionAtPath(
+ root: Record,
+ jsonPath: BackupRedactionPath
+): boolean {
+ let value: unknown = root;
+ for (const segment of jsonPath) {
+ if (typeof segment === "number") {
+ value = Array.isArray(value) ? value[segment] : undefined;
+ continue;
+ }
+ const record = readRecord(value);
+ value = record ? readOwn(record, segment) : undefined;
+ }
+ return typeof value === "string" && containsRedaction(value);
+}
+
+/**
+ * `NAME=value` assignments in a command string are how stdio servers get credentials
+ * (`GRAFANA_SERVICE_ACCOUNT_TOKEN=... mcp-grafana`), and nothing here can say which values
+ * are secret, so every assignment value is replaced. Matched anywhere in the string, not
+ * just before the program name, so `env NAME=value cmd` and trailing `KEY=value` arguments
+ * are covered too. The value grammar consumes a whole shell word, escaped characters and
+ * quoted segments included, so an escape cannot carry part of the value past the
+ * replacement. Restore puts the whole local command back at that path.
+ */
+// The shell ends a word at these without whitespace, so an assignment can directly follow
+// one (`bootstrap;TOKEN=... mcp`) and an unquoted value ends at the next one. Braces are
+// deliberately absent: brace expansion happens within one word and non-expanding braces
+// are literal, so braces travel inside names and values, where a replaced marker
+// distributes safely through any expansion (`TOK{A,B}=x` becomes `TOKA=x TOKB=x`).
+const SHELL_WORD_BREAK = ";&|<>()`";
+// Only space, tab, and newline delimit words for Bash. JS `\s` would also break on NBSP
+// and its other Unicode cousins, which Bash keeps inside the word: an assignment value
+// would end early there, publishing the rest of the runtime value as its own word.
+const SHELL_BLANK = " \\t\\n";
+// Any non-option word up to an unquoted `=` is an assignment name: GNU `env` accepts
+// arbitrary `NAME=VALUE` operands (`TOKEN:NAME=x`, `TOKEN+=x`), and Bash's identifier
+// rule is just the narrow case. Quoting, `$`, and `=` end a name; a leading dash is an
+// option word (`--transport=stdio`), which stays published.
+const ASSIGNMENT_NAME = `[^-${SHELL_BLANK}\\\\'"$=${SHELL_WORD_BREAK}][^${SHELL_BLANK}\\\\'"$=${SHELL_WORD_BREAK}]*=`;
+const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^${SHELL_BLANK}\\\\'"${SHELL_WORD_BREAK}]+)+`;
+const COMMAND_ENV_ASSIGNMENT = new RegExp(
+ `(^|[${SHELL_BLANK}${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
+ "g"
+);
+
+/**
+ * An assignment value the word grammar could not fully consume: after replacement its
+ * remainder trails the marker, or the whole match failed and the original text follows the
+ * `=`. Either way the value's true extent is unknowable, e.g. an unterminated quote.
+ */
+const UNCONSUMED_ASSIGNMENT = new RegExp(
+ `(^|[${SHELL_BLANK}${SHELL_WORD_BREAK}])${ASSIGNMENT_NAME}` +
+ `(?!${REDACTED_BACKUP_VALUE}(?=[${SHELL_BLANK}${SHELL_WORD_BREAK}]|$))(?=[^${SHELL_BLANK}${SHELL_WORD_BREAK}])`
+);
+
+/** One whole shell word, however its quoted and escaped segments interleave. */
+const SHELL_WORD = new RegExp(
+ `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^${SHELL_BLANK}\\\\'"${SHELL_WORD_BREAK}])+`,
+ "g"
+);
+const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
+
+/**
+ * A parameter expansion that can turn into nothing at runtime: an unset variable.
+ * Deleting it models the vanish-splice (`ghp_aaa$NOPE"bbb"` joins around the expansion
+ * the quote boundary ends). Redaction localizes every command holding an active
+ * expansion before anything publishes, so this deletion survives only as the
+ * backstop's independent model of that splice over the finished payload.
+ */
+const SIMPLE_EXPANSION = /^\$[A-Za-z_][A-Za-z0-9_]*/;
+
+/**
+ * Bash-accurate quote removal, in both directions on purpose: under-stripping would hide
+ * disguised assignments, while over-stripping would join quoted fragments the shell
+ * keeps apart and manufacture no-override credential matches (`'ghp_aa\\bb'` keeps its
+ * backslash at runtime). `stripExpansions` deletes simple parameter expansions only in
+ * the contexts where the shell expands them, so a single-quoted or escaped dollar stays
+ * the literal the process receives.
+ */
+function unquoteShellWord(word: string, stripExpansions = false, collapseGlobs = false): string {
+ let result = "";
+ let i = 0;
+ while (i < word.length) {
+ const char = word[i];
+ // ANSI-C ($'...') and locale ($"...") quoting hand the consumer their inner text.
+ if (char === "$" && (word[i + 1] === "'" || word[i + 1] === '"')) {
+ i += 1;
+ continue;
+ }
+ if (char === "$" && stripExpansions) {
+ const expansion = SIMPLE_EXPANSION.exec(word.slice(i));
+ if (expansion) {
+ i += expansion[0].length;
+ continue;
+ }
+ }
+ if (char === "\\") {
+ // A line continuation disappears entirely. Only backslash-LF: before CRLF the
+ // backslash escapes the CR, which stays a literal character and breaks the word.
+ if (word[i + 1] === "\n") {
+ i += 2;
+ continue;
+ }
+ result += word[i + 1] ?? "";
+ i += 2;
+ continue;
+ }
+ if (char === "'") {
+ const end = word.indexOf("'", i + 1);
+ result += word.slice(i + 1, end);
+ i = end + 1;
+ continue;
+ }
+ if (char === '"') {
+ let j = i + 1;
+ while (j < word.length && word[j] !== '"') {
+ // Expansions stay active inside double quotes.
+ if (word[j] === "$" && stripExpansions) {
+ const expansion = SIMPLE_EXPANSION.exec(word.slice(j));
+ if (expansion) {
+ j += expansion[0].length;
+ continue;
+ }
+ }
+ if (word[j] === "\\") {
+ const next = word[j + 1] ?? "";
+ // Inside double quotes the shell unescapes only these; any other
+ // backslash stays a literal character.
+ if (next === "$" || next === "`" || next === '"' || next === "\\") {
+ result += next;
+ j += 2;
+ continue;
+ }
+ if (next === "\n") {
+ j += 2;
+ continue;
+ }
+ result += word[j];
+ j += 1;
+ continue;
+ }
+ result += word[j];
+ j += 1;
+ }
+ i = j + 1;
+ continue;
+ }
+ if (collapseGlobs) {
+ // Pathname expansion is live in this unquoted context. A single caseless
+ // member is deterministic (`[8]` can only produce `8`), and any reader
+ // collapses the published spelling the same way, so scan what it yields.
+ // Letter members and nondeterministic wildcards never reach this scan:
+ // redaction localizes their whole command (nocaseglob makes letters casefold).
+ if (char === "[" && word[i + 2] === "]") {
+ const member = word[i + 1] ?? "";
+ if (!"!^]\\'\"".includes(member) && !/[A-Za-z]/.test(member)) {
+ result += member;
+ i += 3;
+ continue;
+ }
+ }
+ }
+ result += char;
+ i += 1;
+ }
+ return result;
+}
+
+/**
+ * The words Bash would execute: an unquoted `#` opening a word after a blank (or the
+ * string start) discards the rest of that line before quote removal even applies, so
+ * scanning a comment would manufacture no-override matches from prose the process never
+ * sees. Text past the newline is live again and re-tokenized from scratch, because a
+ * quoted word begun inside the comment must not swallow it.
+ */
+function executedShellWords(text: string): string[] {
+ const words: string[] = [];
+ let rest: string | undefined = text;
+ while (rest !== undefined) {
+ const current: string = rest;
+ rest = undefined;
+ for (const match of current.matchAll(SHELL_WORD)) {
+ const start = match.index;
+ const before = start === 0 ? "" : (current[start - 1] ?? "");
+ // Any word break opens a comment position, not just blanks: `cmd;# ...` comments,
+ // and where the grammar wanted a word instead (`>#f`) Bash reports a syntax error
+ // and executes nothing, so skipping the text cannot hide a live word either way.
+ // Leading backslash-LF continuations disappear before tokenization, so a word
+ // spelled `\#...` opens the same comment its unwrapped form would.
+ if (
+ match[0].replace(/^(?:\\\n)+/, "").startsWith("#") &&
+ (start === 0 ||
+ before === " " ||
+ before === "\t" ||
+ before === "\n" ||
+ SHELL_WORD_BREAK.includes(before))
+ ) {
+ const lineEnd = current.indexOf("\n", start);
+ if (lineEnd !== -1) rest = current.slice(lineEnd + 1);
+ break;
+ }
+ words.push(match[0]);
+ }
+ }
+ return words;
+}
+
+/**
+ * GNU `env -S`/`--split-string` re-splits its attached value into assignments, and GNU
+ * getopt accepts any unique long-option abbreviation. No other `env` long option starts
+ * with `s`, so every `--s...` prefix spelling (`--s=`, `--split=`) resolves to it.
+ */
+function isSplitStringOption(unquoted: string): boolean {
+ // -u/-C/-a consume the rest of their word, so an S inside that operand is not
+ // a clustered split-string flag. Only no-argument short options may precede -S.
+ if (/^-[i0v]*S/.test(unquoted)) return true;
+ const abbreviation = /^--([A-Za-z-]*)=/.exec(unquoted);
+ return (
+ abbreviation !== null && abbreviation[1] !== "" && "split-string".startsWith(abbreviation[1])
+ );
+}
+
+/** GNU env options whose following word is an option value, not COMMAND. */
+function envOptionTakesSeparateValue(unquoted: string): boolean {
+ if (unquoted === "-u" || unquoted === "-C" || unquoted === "-a") return true;
+ const abbreviation = /^--([A-Za-z-]+)$/.exec(unquoted)?.[1];
+ return (
+ abbreviation !== undefined &&
+ ("unset".startsWith(abbreviation) ||
+ "chdir".startsWith(abbreviation) ||
+ "argv0".startsWith(abbreviation))
+ );
+}
+
+/** Git global options whose following word is a value, not the subcommand. */
+function gitOptionTakesSeparateValue(unquoted: string): boolean {
+ return /^(?:-[cC]|--(?:git-dir|work-tree|namespace|super-prefix|config-env))$/.test(unquoted);
+}
+
+/** Git config options whose following word is an option value, not the key. */
+function gitConfigOptionTakesSeparateValue(unquoted: string): boolean {
+ return /^(?:-[ft]|--(?:file|blob|type|comment|default))$/.test(unquoted);
+}
+
+/** Java class-path options whose value may itself be an executable archive. */
+function isJavaClassPathOption(unquoted: string): boolean {
+ return /^(?:-cp|-classpath|--class-path)$/.test(unquoted);
+}
+
+/** Java launcher options whose following word is opaque, not the source file. */
+function javaOptionTakesSeparateValue(unquoted: string): boolean {
+ return /^(?:-p|--(?:module-path|upgrade-module-path|add-modules|enable-native-access|describe-module|add-reads|add-exports|add-opens|limit-modules|patch-module))$/.test(
+ unquoted
+ );
+}
+
+function javaClassPathPublishesExecutable(
+ value: string,
+ rootPrefixes: readonly string[],
+ currentDirectory: string | null
+): boolean {
+ return value.split(path.delimiter).some((entry) => {
+ if (
+ isAutoPublishedScriptOperand(entry, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ) {
+ return true;
+ }
+ const resolved = resolveKnownDirectory(entry, currentDirectory);
+ return (
+ resolved !== null &&
+ (isAutoPublishedScriptOperand(resolved, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(resolved), rootPrefixes))
+ );
+ });
+}
+
+function javaPatchModulePublishesExecutable(
+ value: string,
+ rootPrefixes: readonly string[],
+ currentDirectory: string | null
+): boolean {
+ const separator = value.indexOf("=");
+ return (
+ separator !== -1 &&
+ javaClassPathPublishesExecutable(value.slice(separator + 1), rootPrefixes, currentDirectory)
+ );
+}
+
+/**
+ * Git config values that Git later executes as commands or helper processes. Driver,
+ * tool, and hook names are user-chosen subsections that may themselves contain dots,
+ * so those middles match greedily.
+ */
+const GIT_COMMAND_CONFIG_KEY =
+ /^(?:core\.(?:sshcommand|askpass|editor|pager|gitproxy|alternaterefscommand)|sequence\.editor|diff\.(?:external|.+\.(?:command|textconv))|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|gpg\.ssh\.defaultkeycommand|pager\.[^.]+|(?:diff|merge)tool\..+\.cmd|guitool\..+\.cmd|merge\..+\.driver|hook\..+\.command|browser\..+\.(?:cmd|path)|filter\..+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\..+\.cmd|tar\..+\.command|sendemail\.(?:sendmailcmd|cccmd|tocmd)|uploadpack\.packobjectshook|gc\.recentobjectshook)$/i;
+
+/**
+ * core.fsmonitor doubles as a boolean toggle for the built-in monitor; only a
+ * non-boolean value is the hook pathname Git executes. Git reads the boolean with
+ * its maybe-bool parser, which accepts these spellings and any integer.
+ */
+const GIT_FSMONITOR_CONFIG_KEY = /^core\.fsmonitor$/i;
+const GIT_BOOLEAN_CONFIG_VALUE = /^(?:true|false|yes|no|on|off|[+-]?[0-9]+)$/i;
+
+/** Git config keys whose value names another config file Git reads and applies. */
+const GIT_INCLUDE_PATH_CONFIG_KEY = /^include(?:if\..+)?\.path$/i;
+
+/**
+ * Whether a `git -c`/`--config-env` override names a key whose value Git later
+ * executes or reads as config. The key alone decides: the assignment redaction
+ * replaces an unquoted `-c` value before this scan runs, a quote-mangled value
+ * localizes through the disguised-assignment rules, and `--config-env` reads a
+ * variable this scan cannot see, so a sensitive key fails closed on all three.
+ */
+function gitConfigOverrideNamesSensitiveKey(unquoted: string): boolean {
+ const separator = unquoted.indexOf("=");
+ const key = separator === -1 ? unquoted : unquoted.slice(0, separator);
+ return (
+ /^(?:alias\.[^.]+|submodule\..+\.update)$/i.test(key) ||
+ GIT_FSMONITOR_CONFIG_KEY.test(key) ||
+ GIT_INCLUDE_PATH_CONFIG_KEY.test(key) ||
+ GIT_COMMAND_CONFIG_KEY.test(key)
+ );
+}
+
+/**
+ * Documentation is the only thing a recursive collection publishes without asking.
+ * An interpreter that executes one of these files can reconstruct a credential across
+ * the command and file even when neither spelling matches the token backstop.
+ */
+const AUTO_PUBLISHED_RECURSIVE_FILE = /\.(?:md|mdx|markdown|txt)$/i;
+
+/**
+ * Lowercased forward-slash spelling with redundant separators and dot segments
+ * collapsed lexically (`//`, `/./`, `a/../`), without trailing separators, for prefix
+ * compares. Lexical `..` collapse can differ from the filesystem across symlinks,
+ * which can only localize a spelling that resolves elsewhere, failing closed.
+ */
+function normalizeComparablePath(value: string): string {
+ return path.posix.normalize(value.replaceAll("\\", "/")).replace(/\/+$/, "").toLowerCase();
+}
+
+/**
+ * The spellings a command can use for the directory this backup actually collects:
+ * the configured root (a custom XUM_ROOT or a `.xum-dev` build's root included), its
+ * pre-rename alias when the basename carries the product name (a config written
+ * before the rename spells the same collected files under `.mux`), and the `~/`
+ * shorthand for any of them under the home directory. Comparison is case-insensitive:
+ * Windows paths are, and folding a Unix spelling can only localize more.
+ */
+function collectedDocumentRootPrefixes(muxRoot: string): string[] {
+ const absolute = new Set();
+ // Collection follows a symlinked root to its target, so a command can name the
+ // same collected files through the canonical spelling; when resolution fails the
+ // configured spelling still covers the common case.
+ const spellings = [muxRoot];
+ try {
+ spellings.push(realpathSync(muxRoot));
+ } catch {
+ // Ignored: an unresolvable root keeps only its configured spelling.
+ }
+ for (const spelling of spellings) {
+ const root = normalizeComparablePath(spelling);
+ if (root === "") continue;
+ absolute.add(root);
+ const basename = root.slice(root.lastIndexOf("/") + 1);
+ const renamed = basename.startsWith(".xum")
+ ? `.mux${basename.slice(4)}`
+ : basename.startsWith(".mux")
+ ? `.xum${basename.slice(4)}`
+ : null;
+ if (renamed !== null) absolute.add(root.slice(0, root.length - basename.length) + renamed);
+ }
+ const prefixes = new Set(absolute);
+ const home = normalizeComparablePath(os.homedir());
+ // Bash also expands the current user's named-home form (`~alice/...`) to the same
+ // directory. userInfo can throw on systems without a passwd entry; the bare `~`
+ // spelling still covers the common case then.
+ let username = "";
+ try {
+ username = os.userInfo().username.toLowerCase();
+ } catch {
+ username = "";
+ }
+ if (home !== "") {
+ for (const candidate of absolute) {
+ if (!candidate.startsWith(`${home}/`)) continue;
+ prefixes.add(`~${candidate.slice(home.length)}`);
+ if (username !== "") prefixes.add(`~${username}${candidate.slice(home.length)}`);
+ }
+ }
+ return [...prefixes];
+}
+
+/**
+ * Whether the operand names a file this backup publishes automatically, resolved
+ * against the collected root's spellings rather than any `.xum` path segment: a
+ * relative or project-local path (`./.xum/skills/server.txt`) resolves against the
+ * server's own working directory, never the collected root, so localizing it would
+ * only remove a portable launcher on a fresh-device restore.
+ */
+function isAutoPublishedScriptOperand(unquoted: string, rootPrefixes: readonly string[]): boolean {
+ const normalized = normalizeComparablePath(unquoted);
+ for (const prefix of rootPrefixes) {
+ if (!normalized.startsWith(`${prefix}/`)) continue;
+ const relative = normalized.slice(prefix.length + 1);
+ if (relative === "agents.md") return true;
+ if (/^agents\/[^/]+\.md$/.test(relative)) return true;
+ if (
+ /^(?:skills|memory\/global)\//.test(relative) &&
+ AUTO_PUBLISHED_RECURSIVE_FILE.test(relative)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+/** Resolve an absolute/home target, or a relative target from a known directory. */
+function resolveKnownDirectory(target: string, current: string | null): string | null {
+ if (/^(?:\/|\\|~|[a-z]:)/i.test(target)) return normalizeComparablePath(target);
+ if (current === null) return null;
+ return normalizeComparablePath(`${current}/${target}`);
+}
+
+/** Whether the operand names the collected root itself or a directory inside it. */
+function isUnderCollectedRoot(unquoted: string, rootPrefixes: readonly string[]): boolean {
+ const normalized = normalizeComparablePath(unquoted);
+ return rootPrefixes.some(
+ (prefix) => normalized === prefix || normalized.startsWith(`${prefix}/`)
+ );
+}
+
+/**
+ * Inherited environment paths resolve on this machine, so a symlinked spelling
+ * reaches the same collected documents; the canonical target decides. A path
+ * that does not resolve keeps its lexical spelling, and a relative spelling
+ * stays lexical because it resolves against the server's own working
+ * directory, not this process's.
+ */
+function canonicalizeInheritedPath(target: string): string {
+ if (!/^(?:\/|\\|[a-z]:)/i.test(target)) return target;
+ try {
+ return realpathSync(target);
+ } catch {
+ return target;
+ }
+}
+
+/** Known npm commands and aliases terminate global-option parsing. */
+const NPM_SUBCOMMANDS = new Set(
+ "access adduser audit bugs cache ci completion config dedupe deprecate diff dist-tag docs doctor edit exec explain explore find-dupes fund get help help-search hook init install install-ci-test install-test link ll login logout ls org outdated owner pack ping pkg prefix profile prune publish query rebuild repo restart root run-script sbom search set shrinkwrap star stars start stop team test token uninstall unpublish unstar update version view whoami add add-user author c cit clean-install clean-install-test create ddp dist-tags find hlep home i ic in info innit ins inst insta instal install-clean isnt isnta isntal isntall isntall-clean issues it la list ln ogr r rb remove rm rum run s se show sit t tst udpate un unlink up upgrade urn v verison why x".split(
+ " "
+ )
+);
+
+const UV_SUBCOMMANDS = new Set(
+ "auth run init add remove version sync lock export tree format tool python pip venv build publish cache self generate-shell-completion help".split(
+ " "
+ )
+);
+const UV_TOOL_SUBCOMMANDS = new Set("run install upgrade uninstall update list dir".split(" "));
+
+function uvGlobalOptionTakesSeparateValue(unquoted: string): boolean {
+ return /^(?:--(?:cache-dir|color|directory|project|config-file|python-preference|allow-insecure-host))$/.test(
+ unquoted
+ );
+}
+
+/** Exactly one replaced assignment, nothing else riding along in the same word. */
+const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
+
+/**
+ * The word with every quoted or escaped character reduced to one placeholder, so a
+ * syntax test sees only the regions Bash parses as syntax: a quoted comma cannot
+ * trigger brace expansion and a quoted bracket cannot open a glob class. The
+ * placeholder keeps the active fragments around a quoted run from splicing into
+ * syntax that never existed (`{a.'x'.b}` must not read as `{a..b}`).
+ */
+function activeWordProjection(word: string): string {
+ let result = "";
+ let i = 0;
+ while (i < word.length) {
+ const char = word[i];
+ if (char === "\\") {
+ result += "_";
+ i += 2;
+ continue;
+ }
+ if (char === "'") {
+ const end = word.indexOf("'", i + 1);
+ result += "_";
+ i = end === -1 ? word.length : end + 1;
+ continue;
+ }
+ if (char === '"') {
+ let j = i + 1;
+ while (j < word.length && word[j] !== '"') {
+ j += word[j] === "\\" ? 2 : 1;
+ }
+ result += "_";
+ i = j + 1;
+ continue;
+ }
+ result += char;
+ i += 1;
+ }
+ return result;
+}
+
+/**
+ * A glob whose output depends on the working directory: `?`, `*`, and any class other
+ * than `[c]` with one plain literal member expand against whatever files exist, so a
+ * wildcard inside a known token prefix (`gh?_...`) can hand the process a credential no
+ * textual scan reconstructs. Escaped, quoted, negated, and `]` members are excluded
+ * from the deterministic form: the projection cannot represent them faithfully, and
+ * only the plain `[c]` spelling is what the scan's collapse pass reproduces. A single
+ * quote-aware pass rather than a regex, because a regex restarts its `]` search at
+ * every bracket of a long literal `[` run, going quadratic on input an 8 MB mcp.jsonc
+ * can deliver to this synchronous scan. Unmatched `[` stays literal for Bash but
+ * localizes here, one more undecidable-cheap case.
+ */
+function hasNondeterministicGlob(word: string): boolean {
+ let i = 0;
+ while (i < word.length) {
+ const char = word[i];
+ if (char === "\\") {
+ i += 2;
+ continue;
+ }
+ if (char === "'") {
+ const end = word.indexOf("'", i + 1);
+ i = end === -1 ? word.length : end + 1;
+ continue;
+ }
+ if (char === '"') {
+ let j = i + 1;
+ while (j < word.length && word[j] !== '"') {
+ j += word[j] === "\\" ? 2 : 1;
+ }
+ i = j + 1;
+ continue;
+ }
+ if (char === "?" || char === "*") return true;
+ if (char === "[") {
+ // A letter member is only deterministic case-sensitively; with nocaseglob
+ // inherited via BASHOPTS, `[P]` matches a lowercase `p` file, so letters
+ // localize and only caseless members (digits, symbols) collapse.
+ const member = word[i + 1] ?? "";
+ if (word[i + 2] === "]" && !"!^]\\'\"".includes(member) && !/[A-Za-z]/.test(member)) {
+ i += 3;
+ continue;
+ }
+ return true;
+ }
+ i += 1;
+ }
+ return false;
+}
+
+/**
+ * A brace group holding `,` or `..` at any nesting depth expands, and expansion output
+ * can reassemble a credential from fragments no scanner recognizes (`ghp_...{8..8}...`,
+ * nested `gh{p,{x}}_...`). Literal braces (`{hunter2}`) do not expand and stay inside
+ * the word the ordinary rules cover. A depth stack rather than a flat regex, because an
+ * inner non-expanding group otherwise hides the expanding outer one. Runs on the active
+ * projection, so quoted commas stay inert.
+ */
+function hasActiveBraceExpansion(active: string): boolean {
+ const groupExpands: boolean[] = [];
+ let i = 0;
+ while (i < active.length) {
+ const char = active[i];
+ if (char === "{") groupExpands.push(false);
+ else if (char === "}" && groupExpands.length > 0) {
+ if (groupExpands.pop()) return true;
+ } else if (
+ groupExpands.length > 0 &&
+ (char === "," || (char === "." && active[i + 1] === "."))
+ ) {
+ groupExpands[groupExpands.length - 1] = true;
+ }
+ i += 1;
+ }
+ return false;
+}
+
+/**
+ * Shells whose `-c` payload (or script argument) is reparsed under full expansion
+ * rules: a quoted script with no literal whitespace still synthesizes separators
+ * there (`bash -c 'printf${IFS}%s...'`), so naming one localizes the command.
+ * Matched on the quote-removed word's basename over both separators with a
+ * case-insensitive `.exe` suffix removed, covering `/bin/sh`, `bash.exe`, and
+ * `C:\Tools\PWSH.EXE` spellings alike (Windows names are case-insensitive, and
+ * lowercasing a Unix spelling can only fail closed). A
+ * custom wrapper that reparses its argv is per-program knowledge no shell-syntax
+ * scan can model, the same boundary drawn for `tee` and option semantics; these
+ * names are the shells the platform actually ships.
+ */
+const SHELL_INTERPRETER_NAMES = new Set([
+ "sh",
+ "bash",
+ "dash",
+ "ash",
+ "zsh",
+ "ksh",
+ "mksh",
+ "csh",
+ "tcsh",
+ "fish",
+ "busybox",
+ // cmd.exe reparses its /c operand, consuming carets that split fragments upstream.
+ "cmd",
+ "pwsh",
+ "powershell",
+]);
+
+/**
+ * Executables that evaluate a program operand by default, with no `-c`/`-e` marker to
+ * distinguish it from a file launcher: awk runs its first operand as a program, and
+ * GNU sed's `e` command hands script text from the same positional slot to a shell.
+ * Localize the invocation as soon as its exact normalized executable name appears; the
+ * portability cost of `awk -f`/`sed -f` is preferable to parsing each implementation's
+ * option grammar and failing open.
+ */
+const PROGRAM_OPERAND_INTERPRETER_NAMES = new Set([
+ "awk",
+ "gawk",
+ "mawk",
+ "nawk",
+ "goawk",
+ "sed",
+ "gsed",
+]);
+
+/**
+ * Executables whose operands are handed to another shell parse this scan never sees.
+ * Remotely: OpenSSH sends command words to the remote login shell for re-evaluation
+ * (`ssh host mcp --token a\\b` loses the second backslash remotely), and the
+ * scp/rsync remote-path grammars expand through that same remote shell. Locally:
+ * su/runuser/sudo hand their command operand to the target user's shell, and
+ * watch/flock/script/tmux run theirs through a `sh -c`-style pass. Naming one
+ * localizes the invocation, accepting the portability cost like the program-operand
+ * interpreters above.
+ */
+const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
+ "ssh",
+ "slogin",
+ "autossh",
+ "scp",
+ "rsync",
+ "su",
+ "runuser",
+ "sudo",
+ "watch",
+ "flock",
+ "script",
+ "tmux",
+ // xargs' default input parsing removes backslashes and quotes from stdin or an
+ // `-a` argument file, reconstructing a token a collected file carries split; GNU
+ // parallel additionally runs its composed command lines through a shell.
+ "xargs",
+ "parallel",
+]);
+
+/**
+ * Reserved words that leave the following word in command position
+ * (`if sh -c x; then`). A quoted spelling is a keyword to no shell, but treating it
+ * alike only widens the checked positions, failing closed. `for`, `case`, and
+ * `select` bind a name or pattern next, not a command, so they end command position
+ * like any operand.
+ */
+const SHELL_COMMAND_KEYWORDS = new Set([
+ "if",
+ "then",
+ "elif",
+ "else",
+ "do",
+ "while",
+ "until",
+ "!",
+ "{",
+ "}",
+ // Both run what follows: coproc executes its command asynchronously, and a
+ // function body executes at the call site later in the same command string.
+ // Their optional/required NAME operand is handled where the keyword is seen.
+ "coproc",
+ "function",
+]);
+
+/**
+ * Wrappers that run their first operand as a command under this same shell parse: the
+ * name itself evaluates nothing, so a portable launcher merely named in an argument
+ * stays published, but the wrapped command word is checked exactly like a command
+ * start. The count is the leading non-option operands the wrapper consumes first
+ * (`timeout 30 CMD`, `chroot /root CMD`).
+ */
+const COMMAND_CARRIER_OPERANDS = new Map([
+ ["nohup", 0],
+ ["setsid", 0],
+ ["stdbuf", 0],
+ ["nice", 0],
+ ["ionice", 0],
+ ["doas", 0],
+ ["unshare", 0],
+ ["nsenter", 0],
+ ["strace", 0],
+ ["ltrace", 0],
+ ["time", 0],
+ ["command", 0],
+ ["builtin", 0],
+ ["exec", 0],
+ ["prlimit", 0],
+ ["setpriv", 0],
+ ["numactl", 0],
+ ["eatmydata", 0],
+ // systemd executors share the [OPTIONS...] COMMAND grammar; a dash option makes
+ // the walk sticky below, which also covers their separate-value option spellings.
+ ["systemd-run", 0],
+ ["systemd-inhibit", 0],
+ ["systemd-cat", 0],
+ ["timeout", 1],
+ ["chrt", 1],
+ ["taskset", 1],
+ ["chroot", 1],
+ ["runcon", 1],
+ // -1: the wrapper's leading operand is optional (setarch [ARCH] COMMAND), so no
+ // fixed count is safe; every following word is checked instead, failing closed.
+ ["setarch", -1],
+ // The util-linux setarch hard links imply the architecture, so their first
+ // operand is already the program.
+ ["linux32", 0],
+ ["linux64", 0],
+ ["uname26", 0],
+ // CMake executes several operand grammars (-P script mode, -E env/chdir/time
+ // command mode) and CTest runs -S/-SP dashboard scripts; checking every operand
+ // covers them all without modeling each option, failing closed like setarch.
+ ["cmake", -1],
+ ["ctest", -1],
+]);
+
+/**
+ * find's -exec family hands the operands that follow to execvp as a command.
+ * Localizing on the primary itself skips modeling the `;`/`+` terminator grammar,
+ * accepting the portability cost like the program-operand interpreters.
+ */
+const FIND_EXECUTABLE_NAMES = new Set(["find", "gfind"]);
+const FIND_EXEC_PRIMARY = /^-(?:exec|execdir|ok|okdir)$/;
+
+/**
+ * Language interpreters whose script-evaluation spellings reparse an operand under the
+ * language's own grammar, where quoted fragments concatenate into one runtime value
+ * (`python3 -c '..."ghp_a"+"b"...'`, `node -e "...'ghp_a'+'b'..."`, `deno eval ...`).
+ * Only the evaluation spelling localizes: file launchers (`node server.js`,
+ * `python -m pkg`) are the everyday portable MCP commands and stay published. Cluster
+ * spellings count (`-Bc`, `-pe`), and digits cluster too: perl and ruby take the
+ * numeric `-0[octal]` switch before the eval letter (`-0e`). Which letters evaluate
+ * is per-interpreter knowledge this table owns, unlike arbitrary programs' options.
+ */
+interface LanguageInterpreter {
+ name: RegExp;
+ evalWord?: RegExp;
+ attachedScriptFile?: RegExp;
+ separateScriptFileOption?: RegExp;
+ /** Option whose following script name is resolved through inherited PATH. */
+ pathScriptFileOption?: RegExp;
+ /** Captures an attached cwd, or an empty string when the next word is the cwd. */
+ workingDirectoryOption?: RegExp;
+ /**
+ * Interactive-mode spelling that executes the interpreter's inherited startup
+ * file (PYTHONSTARTUP). The cluster prefix excludes E and I, which disable
+ * environment inspection, and letters that consume an attached argument.
+ */
+ interactiveOption?: RegExp;
+ /** Option that enables an inherited debugger program (PERL5DB). */
+ debuggerOption?: RegExp;
+ /**
+ * Attached option naming an auxiliary file consumed before the main operand
+ * (jshell --startup=FILE, PHP -cFILE). Such a file can inject executable behavior,
+ * but it is not the script boundary: a positional script can still follow, so
+ * interpreter tracking stays armed. Separate spellings need no matcher because
+ * the following published operand already localizes through armed tracking.
+ */
+ attachedStartupFile?: RegExp;
+}
+
+/**
+ * Launchers the Node distribution itself ships as `#!/usr/bin/env node` scripts,
+ * so inherited NODE_OPTIONS preloads execute for them exactly as for node.
+ */
+const NODE_BASED_LAUNCHER_NAMES = new Set(["node", "nodejs", "npm", "npx", "corepack"]);
+const PYTHON_LAUNCHER_NAME = /^(?:py|pyw|pythonw?[0-9.]*)$/;
+const PHP_LAUNCHER_NAME = /^(?:php[0-9.]*|php-win)$/;
+const JAVA_RUNTIME_LAUNCHER_NAME = /^(?:javaw?|jshell[0-9.]*)$/;
+const PERL_LAUNCHER_NAME = /^w?perl[0-9.]*$/;
+const LUA_LAUNCHER_NAME = /^(?:lua|luajit)[0-9.]*$/;
+
+const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
+ // Windows spellings count alongside the Unix names: the `py`/`pyw` launcher and the
+ // windowed `pythonw`/`rubyw`/`wperl`/`php-win` builds run the same evaluation
+ // grammars under different executable names. Short eval flags can follow only flags
+ // that consume no attached operand: `-Bc` evaluates, while `-Wsource` does not.
+ {
+ name: PYTHON_LAUNCHER_NAME,
+ evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/,
+ interactiveOption: /^-[bBdhOPqRsuv]*i/,
+ },
+ {
+ name: /^(?:node|nodejs)$/,
+ evalWord:
+ /^(?:(?:--eval|--print|--import|--loader|--experimental-loader|--require)(?:=|$)|-[epr])/,
+ attachedStartupFile: /^--snapshot-blob=(.+)$/,
+ },
+ {
+ name: /^bun$/,
+ evalWord:
+ /^(?:(?:--eval|--print|--import|--loader|--experimental-loader|--preload|--require)(?:=|$)|-[epr])/,
+ },
+ // npx keeps ordinary package launchers portable; only its call operand is reparsed
+ // through a shell. npm needs its `exec` subcommand tracked separately below.
+ { name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
+ { name: /^deno$/, evalWord: /^eval$/ },
+ { name: LUA_LAUNCHER_NAME, evalWord: /^-e/ },
+ { name: /^(?:elixir|iex)[0-9.]*$/, evalWord: /^(?:-e$|--eval(?:=|$)|--rpc-eval(?:=|$))/ },
+ // erl's -eval runs an expression, and -run/-s call Mod:Func with the remaining
+ // words as arguments (`-run os cmd "..."` reaches a shell; os:cmd also accepts
+ // the atoms -s passes), so each hands the grammar executable code.
+ { name: /^w?erl[0-9.]*$/, evalWord: /^-(?:eval|run|s)$/ },
+ // These launchers execute a positional script but need no inline-eval matcher here;
+ // auto-published script operands still localize through the shared check.
+ { name: /^(?:swift|tclsh|wish|expectk?|jimsh|escript)[0-9.]*$/ },
+ { name: /^jshell[0-9.]*$/, attachedStartupFile: /^--startup=(.+)$/ },
+ {
+ name: /^r$/,
+ evalWord: /^(?:-e$|--expression(?:=|$))/,
+ attachedScriptFile: /^(?:--file=|-f)(.+)$/,
+ separateScriptFileOption: /^-f$/,
+ },
+ { name: /^rscript$/, evalWord: /^(?:-e$|--expression(?:=|$))/ },
+ {
+ name: PERL_LAUNCHER_NAME,
+ evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
+ pathScriptFileOption: /^-S$/,
+ debuggerOption: /^-d(?:$|[:t])/,
+ },
+ {
+ name: /^rubyw?[0-9.]*$/,
+ evalWord: /^-(?:(?:0[0-7]*|W[0-2]?)|[acdlnpsvwy])*e/,
+ workingDirectoryOption: /^-C(.*)$/,
+ pathScriptFileOption: /^-S$/,
+ },
+ // -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
+ {
+ name: PHP_LAUNCHER_NAME,
+ evalWord: /^-[nq]*[rRBE]/,
+ attachedScriptFile: /^(?:--file=|--process-file=|-[fF])(.+)$/,
+ separateScriptFileOption: /^(?:-[fF]|--file|--process-file)$/,
+ attachedStartupFile: /^-c(.+)$/,
+ },
+ // make evaluates recipes from an explicit makefile through a shell, and --eval/-E
+ // evaluates the option operand as makefile syntax; plain target launchers stay portable.
+ {
+ name: /^(?:g?make|mingw(?:32|64)-make)$/,
+ evalWord: /^(?:-f|--file(?:=|$)|--makefile(?:=|$)|-E|--eval(?:=|$))/,
+ },
+];
+
+/**
+ * Builtins that rewrite shell state the word scans cannot follow: `eval` reparses its
+ * concatenated arguments, and the others give a shell-built value environment or
+ * parameter visibility without any `=` or `$` spelling. Matched on quote-removed
+ * words, so a binary that merely contains the letters (`evaluate`) stays an argument.
+ */
+const SHELL_STATE_WORDS = new Set([
+ "eval",
+ // Trap actions are reparsed only when their signal fires, after first-parse quotes
+ // have hidden any expansion or escape inside the handler.
+ "trap",
+ // `mapfile`/`readarray` evaluate their `-C` callback text as a command each time
+ // lines are read, after first-parse quotes have hidden what joins inside it.
+ "mapfile",
+ "readarray",
+ // `read` builds a variable from input bytes with backslash joining unless -r, and
+ // inherited allexport exports what it builds.
+ "read",
+ "export",
+ "declare",
+ "typeset",
+ "readonly",
+ "local",
+ "set",
+ // `shopt -so allexport` flips the same allexport state `set -a` does, and
+ // `shopt -s expand_aliases` opens alias rewriting of later lines.
+ "shopt",
+ // `alias` rebinds later command words themselves; POSIX shells expand aliases in
+ // non-interactive scripts, so no later word reliably names what actually runs.
+ "alias",
+ // `enable` rewrites the builtin table: `-f` dlopens FILENAME as builtin NAME (dlopen
+ // needs no .so suffix, so any collected file qualifies), and `-n` makes a builtin
+ // word run a PATH program instead. Either changes what later words execute.
+ "enable",
+ // With inherited SHELLOPTS=history, `history -s` stores its arguments as one entry
+ // and `fc -s` reparses the stored command, expanding what the first parse kept
+ // quoted (`history -s 'mcp${IFS}--token${IFS}ghp_a\\b'; fc -s`).
+ "history",
+ "fc",
+ // `source`/`.` run a file in this shell with the remaining words as positionals
+ // (`source ./launch ghp_aaa bbb` can join them into one runtime token).
+ "source",
+ ".",
+]);
+
+/**
+ * Words that hand a downstream consumer an assignment the shell itself does not see,
+ * none of them decidable here. Only a word that is exactly one consumed assignment is
+ * exempt (`A="B=1"` cannot fire); a marker merely inside a larger word proves nothing
+ * about the rest of that word.
+ */
+function hasDisguisedAssignment(
+ redacted: string,
+ rootPrefixes: readonly string[],
+ inherited: InheritedLaunchContext
+): boolean {
+ let commandPosition = true;
+ let wrappedCommandExpected = false;
+ let carrierArmed = false;
+ let carrierSticky = false;
+ let carrierOperandSkips = 0;
+ let pendingFindPrimaries = false;
+ let pendingBodyName: "coproc" | "function" | null = null;
+ let envOperandsOnly = false;
+ let pendingPrintfVariableOption = false;
+ let sawEnv = false;
+ let pendingEnvOptionValue = false;
+ let pendingEnvWorkingDirectory = false;
+ let pendingNpmSubcommand = false;
+ let pendingNpmExecOptions = false;
+ let pendingUvSubcommand = false;
+ let pendingUvOptionValue = false;
+ let pendingUvToolSubcommand = false;
+ let pendingMiseSubcommand = false;
+ let pendingMiseExecOptions = false;
+ let pendingGitSubcommand = false;
+ let pendingGitOptionValue = false;
+ let pendingGitRemoteProgramOptions: "fetch" | "clone" | "push" | null = null;
+ let pendingGitRemoteProgramValue = false;
+ let pendingGitConfigOverrideValue = false;
+ let pendingGitConfigEnvValue = false;
+ let pendingGitSubmoduleAction = false;
+ let pendingGitRebaseOptions = false;
+ let pendingGitConfigKey = false;
+ let pendingGitConfigOptionValue = false;
+ let pendingGitAliasValue = false;
+ let pendingGitCommandValue = false;
+ let pendingGitFsmonitorValue = false;
+ let pendingGitIncludePathValue = false;
+ let pendingDenoSubcommand = false;
+ let pendingDenoRunScript = false;
+ let pendingDenoRunAmbiguous = false;
+ let pendingSqliteOptions = false;
+ let pendingSqliteInitFile = false;
+ let sqliteDatabaseSeen = false;
+ let pendingLoaderOptions = false;
+ let pendingLoaderPreloadValue = false;
+ let pendingLoaderLibraryPathValue = false;
+ let loaderSearchDirs: string[] = [];
+ let pendingClangOptions = false;
+ let pendingClangForwardedOption = false;
+ let pendingClangPluginMarker = false;
+ let pendingClangPluginOperand = false;
+ let pendingOpensslOptions = false;
+ let pendingOpensslConfigFile = false;
+ let pendingLldbOptions = false;
+ let pendingLldbSourceFile = false;
+ let pendingGdbOptions = false;
+ let pendingGdbCommandFile = false;
+ let pendingNinjaOptions = false;
+ let pendingNinjaBuildFile = false;
+ let pendingTarOptions = false;
+ let pendingTarProgramValue = false;
+ let pendingJavaOptions = false;
+ let pendingJavaSourceVersion = false;
+ let pendingJavaSourceFile = false;
+ let pendingJavaOptionValue = false;
+ let pendingJavaClassPathValue = false;
+ let pendingJavaPatchModuleValue = false;
+ let pendingHashOptions = false;
+ let pendingStartStopDaemonOptions = false;
+ let pendingStartStopDaemonExecutable = false;
+ let pendingSystemdRunOptions = false;
+ let pendingSystemdRunWorkingDirectory = false;
+ let pendingCdBuiltin: "cd" | "pushd" | null = null;
+ // Lexical spelling of the working directory once a cd/pushd chain makes it known;
+ // it survives separators because the moved directory outlives the command.
+ let trackedCwd: string | null = null;
+ let commandWordSeen = false;
+ let commandConsumesStdin = false;
+ let commandPublishedStdin = false;
+ let pendingScriptFileOperand = false;
+ let pendingScriptFileUsesPath = false;
+ let evalOperandAmbiguous = false;
+ // Static table entries keep the pending set bounded, so repeated interpreter words
+ // cannot make these checks superlinear in command length.
+ const pendingLanguages = new Set();
+ const languageWorkingDirectories = new Map();
+ let pendingLanguageWorkingDirectory: LanguageInterpreter | null = null;
+
+ function isShellResolvedPublishedOperand(value: string): boolean {
+ if (
+ isAutoPublishedScriptOperand(value, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(value), rootPrefixes)
+ ) {
+ return true;
+ }
+ const resolved = resolveKnownDirectory(value, trackedCwd);
+ return (
+ resolved !== null &&
+ (isAutoPublishedScriptOperand(resolved, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(resolved), rootPrefixes))
+ );
+ }
+
+ function isPendingLanguageScriptOperand(value: string): boolean {
+ if (isShellResolvedPublishedOperand(value)) return true;
+ for (const language of pendingLanguages) {
+ const directory = languageWorkingDirectories.get(language);
+ if (directory === undefined) continue;
+ const resolved = resolveKnownDirectory(value, directory);
+ if (
+ resolved !== null &&
+ (isAutoPublishedScriptOperand(resolved, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(resolved), rootPrefixes))
+ ) {
+ return true;
+ }
+ }
+ if (pendingScriptFileUsesPath && !/[/\\]/.test(value)) {
+ for (const directory of inherited.publishedPathDirs) {
+ if (isAutoPublishedScriptOperand(`${directory}/${value}`, rootPrefixes)) return true;
+ }
+ }
+ return false;
+ }
+
+ function clearInterpreterTracking(): void {
+ pendingLanguages.clear();
+ languageWorkingDirectories.clear();
+ pendingLanguageWorkingDirectory = null;
+ pendingScriptFileOperand = false;
+ pendingScriptFileUsesPath = false;
+ evalOperandAmbiguous = false;
+ }
+ const words = [...redacted.matchAll(SHELL_WORD)];
+ let previousEnd = 0;
+ for (let index = 0; index < words.length; index += 1) {
+ const word = words[index]?.[0] ?? "";
+ const wordStart = words[index]?.index ?? previousEnd;
+ const gap = redacted.slice(previousEnd, wordStart);
+ previousEnd = wordStart + word.length;
+ // Control and grouping operators start a new command. Of the other break
+ // characters, a live backtick localizes upstream as a carrier and a write
+ // redirection localizes on its own, so only `<` still needs position handling.
+ if (/[;&|()\n]/.test(gap)) {
+ // A control operator starts a new command, so no parser state from the
+ // previous one applies: retained interpreter tracking would read the next
+ // command's ordinary options as evaluation (`python3 --version && mcp -c x`).
+ commandPosition = true;
+ wrappedCommandExpected = false;
+ carrierArmed = false;
+ carrierSticky = false;
+ carrierOperandSkips = 0;
+ pendingFindPrimaries = false;
+ pendingBodyName = null;
+ sawEnv = false;
+ pendingEnvOptionValue = false;
+ pendingEnvWorkingDirectory = false;
+ envOperandsOnly = false;
+ pendingPrintfVariableOption = false;
+ pendingNpmSubcommand = false;
+ pendingNpmExecOptions = false;
+ pendingUvSubcommand = false;
+ pendingUvOptionValue = false;
+ pendingUvToolSubcommand = false;
+ pendingMiseSubcommand = false;
+ pendingMiseExecOptions = false;
+ pendingGitSubcommand = false;
+ pendingGitOptionValue = false;
+ pendingGitRemoteProgramOptions = null;
+ pendingGitRemoteProgramValue = false;
+ pendingGitConfigOverrideValue = false;
+ pendingGitConfigEnvValue = false;
+ pendingGitSubmoduleAction = false;
+ pendingGitRebaseOptions = false;
+ pendingGitConfigKey = false;
+ pendingGitConfigOptionValue = false;
+ pendingGitAliasValue = false;
+ pendingGitCommandValue = false;
+ pendingGitFsmonitorValue = false;
+ pendingGitIncludePathValue = false;
+ pendingDenoSubcommand = false;
+ pendingDenoRunScript = false;
+ pendingDenoRunAmbiguous = false;
+ pendingSqliteOptions = false;
+ pendingSqliteInitFile = false;
+ sqliteDatabaseSeen = false;
+ pendingLoaderOptions = false;
+ pendingLoaderPreloadValue = false;
+ pendingLoaderLibraryPathValue = false;
+ loaderSearchDirs = [];
+ pendingClangOptions = false;
+ pendingClangForwardedOption = false;
+ pendingClangPluginMarker = false;
+ pendingClangPluginOperand = false;
+ pendingOpensslOptions = false;
+ pendingOpensslConfigFile = false;
+ pendingLldbOptions = false;
+ pendingLldbSourceFile = false;
+ pendingGdbOptions = false;
+ pendingGdbCommandFile = false;
+ pendingNinjaOptions = false;
+ pendingNinjaBuildFile = false;
+ pendingTarOptions = false;
+ pendingTarProgramValue = false;
+ pendingJavaOptions = false;
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = false;
+ pendingJavaOptionValue = false;
+ pendingJavaClassPathValue = false;
+ pendingJavaPatchModuleValue = false;
+ pendingHashOptions = false;
+ pendingStartStopDaemonOptions = false;
+ pendingStartStopDaemonExecutable = false;
+ pendingSystemdRunOptions = false;
+ pendingSystemdRunWorkingDirectory = false;
+ // A bare cd goes home; a bare pushd swaps to a stack entry this scan
+ // cannot resolve.
+ if (pendingCdBuiltin === "cd") trackedCwd = "~";
+ if (pendingCdBuiltin === "pushd") trackedCwd = null;
+ pendingCdBuiltin = null;
+ commandWordSeen = false;
+ commandConsumesStdin = false;
+ commandPublishedStdin = false;
+ clearInterpreterTracking();
+ }
+ // The word after `<` is a read redirection's filename, never a command or an
+ // operand; the command word can still follow it (`< input sh -c x`). A published
+ // document as redirected input is executable to a stdin-reading interpreter
+ // (`node < launch.txt` runs it as a script), so that filename localizes.
+ if (gap.includes("<")) {
+ if (isShellResolvedPublishedOperand(unquoteShellWord(word))) {
+ // Published input localizes only when something can execute it: a
+ // stdin-running interpreter in this command or a command word not yet
+ // seen (`< input sh -c x`), which fails closed. A non-interpreter
+ // consumes the document as data (`mcp-server < config.txt` stays
+ // portable). An interpreter can still follow the redirection, so the
+ // published filename stays remembered for that case.
+ if (commandConsumesStdin || !commandWordSeen) return true;
+ commandPublishedStdin = true;
+ }
+ continue;
+ }
+ if (CONSUMED_ASSIGNMENT.test(word)) {
+ if (pendingJavaPatchModuleValue) {
+ pendingJavaPatchModuleValue = false;
+ return true;
+ }
+ // A git -c or --config-env value can itself be the replaced assignment
+ // (`-c core.sshCommand=`): the key still classifies, and the
+ // hidden value fails closed wherever it would decide.
+ if (pendingGitOptionValue) {
+ pendingGitOptionValue = false;
+ const classifiable = pendingGitConfigOverrideValue || pendingGitConfigEnvValue;
+ pendingGitConfigOverrideValue = false;
+ pendingGitConfigEnvValue = false;
+ if (classifiable && gitConfigOverrideNamesSensitiveKey(word)) return true;
+ }
+ continue;
+ }
+ // Bash expands neither syntax from quoted or escaped text (`--config
+ // '{"a":1,"b":2}'` stays a literal argument). The brace test runs on the active
+ // projection; the glob analyzer is quote-aware itself and needs the raw word to
+ // tell `[\p]` (escaped member) from `[p]`.
+ if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
+ if (hasNondeterministicGlob(word)) return true;
+ const unquoted = unquoteShellWord(word);
+ // A bare descriptor immediately before `<` belongs to that redirection
+ // (`2 0;
+ if (scriptOperandFollows) {
+ // Preserve interpreter-specific cwd state until the one script operand is
+ // consumed; only option/eval parsing ends at the terminator.
+ pendingLanguageWorkingDirectory = null;
+ pendingScriptFileOperand = true;
+ evalOperandAmbiguous = false;
+ } else {
+ // A bare dash reads the script from stdin instead.
+ clearInterpreterTracking();
+ }
+ envOperandsOnly ||= sawEnv;
+ // `--` ends env option parsing, so the next word is the utility; a bare `-`
+ // is `-i`, leaving option parsing armed.
+ if (unquoted === "--") {
+ wrappedCommandExpected ||= sawEnv || pendingMiseExecOptions;
+ sawEnv = false;
+ pendingMiseExecOptions = false;
+ pendingStartStopDaemonOptions = false;
+ pendingStartStopDaemonExecutable = false;
+ pendingSystemdRunOptions = false;
+ pendingSystemdRunWorkingDirectory = false;
+ pendingSqliteOptions = false;
+ pendingSqliteInitFile = false;
+ }
+ pendingEnvOptionValue = false;
+ pendingEnvWorkingDirectory = false;
+ pendingNpmExecOptions = false;
+ pendingGitRebaseOptions = false;
+ pendingJavaOptions = false;
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = false;
+ pendingJavaOptionValue = false;
+ pendingJavaClassPathValue = false;
+ pendingJavaPatchModuleValue = false;
+ continue;
+ }
+ if (pendingBodyName !== null) {
+ const keyword = pendingBodyName;
+ pendingBodyName = null;
+ // The NAME between the keyword and its compound body does not execute; the
+ // body's opening `{` keeps command position through the keyword set. coproc
+ // treats the word as a name only when a compound follows; otherwise it is the
+ // simple command itself and falls through to the checks below.
+ if (
+ /^[A-Za-z_][A-Za-z0-9_]*$/.test(unquoted) &&
+ (keyword === "function" || unquoteShellWord(words[index + 1]?.[0] ?? "") === "{")
+ ) {
+ continue;
+ }
+ }
+ // Reserved words leave the following word in command position.
+ if (commandPosition && SHELL_COMMAND_KEYWORDS.has(unquoted)) {
+ if (unquoted === "coproc" || unquoted === "function") pendingBodyName = unquoted;
+ continue;
+ }
+ // Whether this word can execute: a command start, env's utility operand, or a
+ // carrier's wrapped command. Only such words can name an interpreter, a wrapper,
+ // or a state-changing builtin; everywhere else the same spelling is an ordinary
+ // argument (`mcp-server --shell bash` stays published).
+ let executesHere = commandPosition || carrierSticky;
+ commandPosition = false;
+ if (wrappedCommandExpected) {
+ wrappedCommandExpected = false;
+ executesHere = true;
+ }
+ // GNU env reparses its split-string value even without an assignment or literal
+ // whitespace, so this runs before the assignment-only exit below. Stop tracking at
+ // its command operand: the target program may use -S for an ordinary option.
+ if (sawEnv) {
+ if (pendingEnvWorkingDirectory) {
+ pendingEnvWorkingDirectory = false;
+ executesHere = false;
+ const directory = resolveKnownDirectory(unquoted, trackedCwd);
+ if (directory !== null && isUnderCollectedRoot(directory, rootPrefixes)) return true;
+ } else if (pendingEnvOptionValue) {
+ pendingEnvOptionValue = false;
+ executesHere = false;
+ } else if (isSplitStringOption(unquoted)) {
+ return true;
+ } else {
+ const attachedChdir = /^(?:-C(.+)|--([A-Za-z-]+)=(.*))$/.exec(unquoted);
+ const longChdir = attachedChdir?.[2];
+ if (
+ attachedChdir?.[1] !== undefined ||
+ (longChdir !== undefined && "chdir".startsWith(longChdir))
+ ) {
+ const value = attachedChdir?.[1] ?? attachedChdir?.[3] ?? "";
+ const directory = resolveKnownDirectory(value, trackedCwd);
+ if (directory !== null && isUnderCollectedRoot(directory, rootPrefixes)) return true;
+ } else {
+ const longOption = /^--([A-Za-z-]+)$/.exec(unquoted)?.[1];
+ if (unquoted === "-C" || (longOption !== undefined && "chdir".startsWith(longOption))) {
+ pendingEnvWorkingDirectory = true;
+ } else if (envOptionTakesSeparateValue(unquoted)) {
+ pendingEnvOptionValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ sawEnv = false;
+ executesHere = true;
+ }
+ }
+ }
+ } else if (carrierArmed) {
+ if (unquoted.startsWith("-")) {
+ // The option may take a separate value this scan cannot pair (the same
+ // boundary as `python3 -W ignore -c x` below), so from here any word may be
+ // the wrapped command and all of them are checked, failing closed.
+ carrierSticky = true;
+ executesHere = true;
+ } else if (carrierOperandSkips > 0) {
+ carrierOperandSkips -= 1;
+ } else {
+ carrierArmed = false;
+ executesHere = true;
+ }
+ }
+ if (pendingGitAliasValue) {
+ pendingGitAliasValue = false;
+ if (unquoted.startsWith("!")) return true;
+ }
+ if (pendingGitCommandValue) {
+ pendingGitCommandValue = false;
+ return true;
+ }
+ if (pendingGitFsmonitorValue) {
+ pendingGitFsmonitorValue = false;
+ if (!GIT_BOOLEAN_CONFIG_VALUE.test(unquoted)) return true;
+ }
+ if (pendingGitIncludePathValue) {
+ pendingGitIncludePathValue = false;
+ // The included config file is read and applied (aliases, command-valued keys),
+ // so including a published document localizes; any other include stays portable.
+ if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
+ }
+ if (pendingGitConfigKey) {
+ if (pendingGitConfigOptionValue) {
+ pendingGitConfigOptionValue = false;
+ } else if (gitConfigOptionTakesSeparateValue(unquoted)) {
+ pendingGitConfigOptionValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingGitConfigKey = false;
+ if (/^alias\.[^.]+$/i.test(unquoted)) {
+ pendingGitAliasValue = true;
+ } else if (/^submodule\..+\.update$/i.test(unquoted)) {
+ // Shares the alias rule: only a `!`-prefixed update value is a command,
+ // which `git submodule update` executes in place of the built-in modes.
+ pendingGitAliasValue = true;
+ } else if (GIT_FSMONITOR_CONFIG_KEY.test(unquoted)) {
+ pendingGitFsmonitorValue = true;
+ } else if (GIT_INCLUDE_PATH_CONFIG_KEY.test(unquoted)) {
+ pendingGitIncludePathValue = true;
+ } else if (GIT_COMMAND_CONFIG_KEY.test(unquoted)) {
+ pendingGitCommandValue = true;
+ }
+ }
+ }
+ if (pendingGitRemoteProgramValue) {
+ pendingGitRemoteProgramValue = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingGitRemoteProgramOptions !== null) {
+ const names =
+ pendingGitRemoteProgramOptions === "push" ? "(?:receive-pack|exec)" : "upload-pack";
+ const attached = new RegExp(`^--${names}=(.+)$`).exec(unquoted)?.[1];
+ if (attached !== undefined && isShellResolvedPublishedOperand(attached)) return true;
+ if (
+ new RegExp(`^--${names}$`).test(unquoted) ||
+ (pendingGitRemoteProgramOptions === "clone" && unquoted === "-u")
+ ) {
+ pendingGitRemoteProgramValue = true;
+ }
+ }
+ if (pendingGitSubmoduleAction) {
+ if (unquoted === "foreach") return true;
+ if (!unquoted.startsWith("-")) pendingGitSubmoduleAction = false;
+ }
+ if (pendingGitRebaseOptions && /^(?:-x|--exec(?:=|$))/.test(unquoted)) return true;
+ if (pendingGitSubcommand) {
+ if (pendingGitOptionValue) {
+ pendingGitOptionValue = false;
+ if (pendingGitConfigOverrideValue || pendingGitConfigEnvValue) {
+ pendingGitConfigOverrideValue = false;
+ pendingGitConfigEnvValue = false;
+ if (gitConfigOverrideNamesSensitiveKey(unquoted)) return true;
+ }
+ } else {
+ const execPath = /^--exec-path=(.*)$/.exec(unquoted)?.[1];
+ if (execPath !== undefined && isUnderCollectedRoot(execPath, rootPrefixes)) return true;
+ const configEnv = /^--config-env=(.*)$/.exec(unquoted)?.[1];
+ if (configEnv !== undefined && gitConfigOverrideNamesSensitiveKey(configEnv)) return true;
+ if (gitOptionTakesSeparateValue(unquoted)) {
+ pendingGitOptionValue = true;
+ pendingGitConfigOverrideValue = unquoted === "-c";
+ pendingGitConfigEnvValue = unquoted === "--config-env";
+ } else if (!unquoted.startsWith("-")) {
+ pendingGitSubcommand = false;
+ if (unquoted === "config") {
+ pendingGitConfigKey = true;
+ } else if (unquoted === "fetch" || unquoted === "pull") {
+ pendingGitRemoteProgramOptions = "fetch";
+ } else if (unquoted === "clone") {
+ pendingGitRemoteProgramOptions = "clone";
+ } else if (unquoted === "push") {
+ pendingGitRemoteProgramOptions = "push";
+ } else if (unquoted === "submodule") {
+ pendingGitSubmoduleAction = true;
+ } else if (unquoted === "rebase") {
+ pendingGitRebaseOptions = true;
+ } else if (unquoted === "filter-branch") {
+ return true;
+ }
+ }
+ }
+ }
+ if (pendingUvToolSubcommand) {
+ if (unquoted === "run") return true;
+ if (UV_TOOL_SUBCOMMANDS.has(unquoted)) pendingUvToolSubcommand = false;
+ }
+ if (pendingUvSubcommand) {
+ if (pendingUvOptionValue) {
+ pendingUvOptionValue = false;
+ } else if (uvGlobalOptionTakesSeparateValue(unquoted)) {
+ pendingUvOptionValue = true;
+ } else if (unquoted === "run") {
+ // uv run executes the command that follows after its own option parse.
+ // Localizing at the subcommand avoids duplicating that evolving grammar.
+ return true;
+ } else if (unquoted === "tool") {
+ pendingUvSubcommand = false;
+ pendingUvToolSubcommand = true;
+ } else if (UV_SUBCOMMANDS.has(unquoted)) {
+ pendingUvSubcommand = false;
+ }
+ }
+ if (pendingMiseExecOptions && /^(?:-c(?:.+)?|--command(?:=|$))/.test(unquoted)) {
+ return true;
+ }
+ if (pendingMiseSubcommand) {
+ if (unquoted === "exec" || unquoted === "x") {
+ pendingMiseSubcommand = false;
+ pendingMiseExecOptions = true;
+ }
+ }
+ if (pendingNpmExecOptions) {
+ if (/^(?:-c|--call(?:=|$))/.test(unquoted)) return true;
+ if (!unquoted.startsWith("-")) pendingNpmExecOptions = false;
+ }
+ if (pendingNpmSubcommand) {
+ if (unquoted === "exec" || unquoted === "x") {
+ pendingNpmSubcommand = false;
+ pendingNpmExecOptions = true;
+ } else if (NPM_SUBCOMMANDS.has(unquoted)) {
+ pendingNpmSubcommand = false;
+ }
+ // Anything else can be a separated value for a global config option
+ // (`--prefix /tmp`), so tracking stays armed until a known subcommand.
+ }
+ if (pendingDenoRunScript) {
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ if (unquoted.startsWith("-")) {
+ // The option may take a separate value this scan cannot pair, so from here
+ // a non-option word no longer proves the entrypoint; tracking stays armed,
+ // failing closed like the interpreter boundary below.
+ pendingDenoRunAmbiguous = true;
+ } else if (!pendingDenoRunAmbiguous) {
+ // The entrypoint ends tracking: later words are that program's arguments,
+ // and a published path among them is data, not something deno executes.
+ pendingDenoRunScript = false;
+ }
+ }
+ if (pendingLoaderPreloadValue) {
+ pendingLoaderPreloadValue = false;
+ if (loaderListPublishesExecutable(unquoted, rootPrefixes, trackedCwd, loaderSearchDirs)) {
+ return true;
+ }
+ continue;
+ }
+ if (pendingLoaderLibraryPathValue) {
+ pendingLoaderLibraryPathValue = false;
+ loaderSearchDirs = unquoted
+ .split(path.delimiter)
+ .map((entry) => resolveKnownDirectory(entry, trackedCwd))
+ .filter((entry): entry is string => entry !== null);
+ continue;
+ }
+ if (pendingLoaderOptions) {
+ const preload = /^--(?:preload|audit)=(.+)$/.exec(unquoted)?.[1];
+ if (
+ preload !== undefined &&
+ loaderListPublishesExecutable(preload, rootPrefixes, trackedCwd, loaderSearchDirs)
+ ) {
+ return true;
+ }
+ const libraryPath = /^--library-path=(.+)$/.exec(unquoted)?.[1];
+ if (libraryPath !== undefined) {
+ loaderSearchDirs = libraryPath
+ .split(path.delimiter)
+ .map((entry) => resolveKnownDirectory(entry, trackedCwd))
+ .filter((entry): entry is string => entry !== null);
+ } else if (/^--(?:preload|audit)$/.test(unquoted)) {
+ pendingLoaderPreloadValue = true;
+ } else if (unquoted === "--library-path") {
+ pendingLoaderLibraryPathValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingLoaderOptions = false;
+ }
+ }
+ if (pendingSqliteInitFile) {
+ pendingSqliteInitFile = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ continue;
+ } else if (pendingSqliteOptions && /^--?init$/.test(unquoted)) {
+ // SQLite accepts every option with one or two leading dashes.
+ pendingSqliteInitFile = true;
+ continue;
+ } else if (pendingSqliteOptions && /^--?cmd$/.test(unquoted)) {
+ // -cmd runs its operand through SQLite's own parse before stdin, an
+ // evaluation channel this scan cannot follow (.shell and dot-command
+ // quoting), so it localizes like other eval words.
+ return true;
+ } else if (pendingSqliteOptions && !unquoted.startsWith("-")) {
+ if (sqliteDatabaseSeen) return true;
+ sqliteDatabaseSeen = true;
+ }
+ if (pendingTarProgramValue) {
+ pendingTarProgramValue = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingTarOptions) {
+ const attachedProgram = /^(?:-I|--use-compress-program=)(.+)$/.exec(unquoted)?.[1];
+ if (attachedProgram !== undefined && isShellResolvedPublishedOperand(attachedProgram)) {
+ return true;
+ }
+ if (unquoted === "-I" || unquoted === "--use-compress-program") {
+ pendingTarProgramValue = true;
+ }
+ }
+ if (pendingOpensslConfigFile) {
+ pendingOpensslConfigFile = false;
+ // A published OpenSSL config can load another collected document as a
+ // dynamic engine object, executing it inside the launcher.
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingOpensslOptions && /^--?config$/.test(unquoted)) {
+ pendingOpensslConfigFile = true;
+ }
+ if (pendingClangPluginOperand) {
+ pendingClangPluginOperand = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ continue;
+ }
+ if (pendingClangPluginMarker) {
+ pendingClangPluginMarker = false;
+ if (unquoted === "-Xclang") {
+ pendingClangPluginOperand = true;
+ continue;
+ }
+ }
+ if (pendingClangForwardedOption) {
+ pendingClangForwardedOption = false;
+ if (unquoted === "-load") pendingClangPluginMarker = true;
+ continue;
+ }
+ if (pendingClangOptions && unquoted === "-Xclang") {
+ pendingClangForwardedOption = true;
+ continue;
+ }
+ if (pendingGdbCommandFile) {
+ pendingGdbCommandFile = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingGdbOptions) {
+ const attachedCommandFile = /^-(?:x|ix)(.+)$/.exec(unquoted)?.[1];
+ const longCommandFile = /^--?(?:command|init-command)=(.+)$/.exec(unquoted)?.[1];
+ const commandFile = attachedCommandFile ?? longCommandFile;
+ if (commandFile !== undefined && isShellResolvedPublishedOperand(commandFile)) return true;
+ if (/^(?:-x|-ix|--?(?:command|init-command))$/.test(unquoted)) {
+ pendingGdbCommandFile = true;
+ }
+ if (/^(?:-ex|-iex|--?(?:eval-command|init-eval-command)(?:=.*)?)$/.test(unquoted)) {
+ return true;
+ }
+ }
+ if (pendingNinjaBuildFile) {
+ pendingNinjaBuildFile = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingNinjaOptions) {
+ const attachedBuildFile = /^-f(.+)$/.exec(unquoted)?.[1];
+ if (attachedBuildFile !== undefined && isShellResolvedPublishedOperand(attachedBuildFile)) {
+ return true;
+ }
+ if (unquoted === "-f") pendingNinjaBuildFile = true;
+ }
+ if (pendingLldbSourceFile) {
+ pendingLldbSourceFile = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingLldbOptions) {
+ const attachedSource = /^--(?:source|source-before-file|source-on-crash)=(.+)$/.exec(
+ unquoted
+ )?.[1];
+ const attachedShortSource = /^-[sSK](.+)$/.exec(unquoted)?.[1];
+ const source = attachedSource ?? attachedShortSource;
+ if (source !== undefined && isShellResolvedPublishedOperand(source)) return true;
+ if (/^(?:-[sSK]|--(?:source|source-before-file|source-on-crash))$/.test(unquoted)) {
+ pendingLldbSourceFile = true;
+ }
+ // One-line options execute the following LLDB command, and attached long
+ // forms execute their value. Either is an eval boundary this scan cannot
+ // safely reinterpret.
+ if (
+ /^(?:-[oOk]|--(?:one-line|one-line-before-file|one-line-on-crash)(?:=|$))/.test(unquoted)
+ ) {
+ return true;
+ }
+ }
+ if (pendingJavaPatchModuleValue) {
+ pendingJavaPatchModuleValue = false;
+ if (javaPatchModulePublishesExecutable(unquoted, rootPrefixes, trackedCwd)) return true;
+ } else if (pendingJavaClassPathValue) {
+ pendingJavaClassPathValue = false;
+ if (javaClassPathPublishesExecutable(unquoted, rootPrefixes, trackedCwd)) return true;
+ } else if (pendingJavaOptionValue) {
+ pendingJavaOptionValue = false;
+ } else if (pendingJavaSourceVersion) {
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = true;
+ } else if (pendingJavaOptions || pendingJavaSourceFile) {
+ if (unquoted.startsWith("@")) {
+ // The launcher expands an @argument-file into options before parsing, so a
+ // published file can inject --source and a script operand; the file itself
+ // localizes, and any other @-file leaves tracking armed because the options
+ // it expands to are not visible here.
+ if (isShellResolvedPublishedOperand(unquoted.slice(1))) return true;
+ } else if (
+ isJavaClassPathOption(unquoted) ||
+ /^(?:-p|--module-path|--upgrade-module-path)$/.test(unquoted)
+ ) {
+ pendingJavaClassPathValue = true;
+ } else if (/^--(?:class-path|module-path|upgrade-module-path)=/.test(unquoted)) {
+ if (
+ javaClassPathPublishesExecutable(
+ unquoted.slice(unquoted.indexOf("=") + 1),
+ rootPrefixes,
+ trackedCwd
+ )
+ ) {
+ return true;
+ }
+ } else if (unquoted === "--patch-module") {
+ pendingJavaPatchModuleValue = true;
+ } else if (unquoted.startsWith("--patch-module=")) {
+ if (
+ javaPatchModulePublishesExecutable(
+ unquoted.slice("--patch-module=".length),
+ rootPrefixes,
+ trackedCwd
+ )
+ ) {
+ return true;
+ }
+ } else if (/^-(?:javaagent|agentpath):/.test(unquoted)) {
+ const agent = /^-(?:javaagent|agentpath):([^=]+)/.exec(unquoted)?.[1];
+ if (agent !== undefined && isShellResolvedPublishedOperand(agent)) return true;
+ } else if (/^-Xbootclasspath(?:\/[ap])?:/.test(unquoted)) {
+ // Boot-class-path entries execute like the class path: they load ahead
+ // of the application regardless of filename extension.
+ if (
+ javaClassPathPublishesExecutable(
+ unquoted.slice(unquoted.indexOf(":") + 1),
+ rootPrefixes,
+ trackedCwd
+ )
+ ) {
+ return true;
+ }
+ } else if (javaOptionTakesSeparateValue(unquoted)) {
+ pendingJavaOptionValue = true;
+ } else if (unquoted === "--source") {
+ pendingJavaSourceVersion = true;
+ } else if (unquoted.startsWith("--source=")) {
+ pendingJavaSourceFile = true;
+ } else if (unquoted === "-jar") {
+ // -jar's operand is executed like a --source script: the archive itself
+ // runs, and the launcher opens it regardless of filename extension.
+ pendingJavaSourceFile = true;
+ } else if (pendingJavaSourceFile && !unquoted.startsWith("-")) {
+ const autoPublished = isShellResolvedPublishedOperand(unquoted);
+ pendingJavaOptions = false;
+ pendingJavaSourceFile = false;
+ if (autoPublished) return true;
+ } else if (pendingJavaOptions && !unquoted.startsWith("-")) {
+ pendingJavaOptions = false;
+ }
+ }
+ if (pendingDenoSubcommand) {
+ if (unquoted === "run") {
+ pendingDenoSubcommand = false;
+ pendingDenoRunScript = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingDenoSubcommand = false;
+ }
+ }
+ if (pendingCdBuiltin !== null && !unquoted.startsWith("-")) {
+ pendingCdBuiltin = null;
+ // An absolute or home-anchored target replaces the working directory, and a
+ // relative target resolves against the last tracked one, staying unknown
+ // when the chain starts from the server's own cwd (a plain `cd build`
+ // launcher stays portable). Once the directory reaches the collected root,
+ // any relative operand can name a published document without spelling the
+ // root at all (`cd ~ && cd .xum && python3 skills/launch.txt`), so the
+ // move itself localizes. Dash words are cd's own options and keep the
+ // target pending.
+ if (!/^(?:\/|\\|~|[a-z]:)/i.test(unquoted)) {
+ // Bash searches inherited CDPATH before its ordinary relative target. A
+ // candidate inside the collected root localizes even when the server's
+ // original cwd is unknown (CDPATH=; cd skills).
+ for (const entry of inherited.cdPathDirs) {
+ const base = entry === "" ? trackedCwd : resolveKnownDirectory(entry, trackedCwd);
+ const candidate = base === null ? null : resolveKnownDirectory(unquoted, base);
+ if (candidate !== null && isUnderCollectedRoot(candidate, rootPrefixes)) return true;
+ }
+ }
+ trackedCwd = resolveKnownDirectory(unquoted, trackedCwd);
+ if (trackedCwd !== null && isUnderCollectedRoot(trackedCwd, rootPrefixes)) return true;
+ }
+ // `hash -p PATHNAME NAME` binds NAME to any full pathname, so every remap
+ // changes what a later word executes (`hash -p /usr/bin/python3 launch` hands
+ // launch's arguments to an installed evaluator); the pathname's location proves
+ // nothing, so any -p spelling localizes. Scanning past bash's option terminator
+ // or its first name operand only fails closed.
+ if (pendingHashOptions && /^-[dlrt]*p/.test(unquoted)) return true;
+ // With allexport inherited through SHELLOPTS, `printf -v` exports the variable it
+ // builds even when this command contains no explicit export/set/shopt word.
+ if (pendingPrintfVariableOption && unquoted.startsWith("-v")) return true;
+ pendingPrintfVariableOption = executesHere && unquoted === "printf";
+ // `eval` concatenates its arguments and reparses the result, dissolving one more
+ // layer of quoting than any single-pass scan models (`ghp_a\\\\b` reaches the
+ // process as `ghp_ab`). The export-family builtins move a shell-built variable
+ // into the environment with no `=` or `$` in the text (`printf -v TOKEN ...;
+ // export TOKEN`), and `set` reaches the same end through `-a` or the positional
+ // parameters. Each is a builtin only where a command can start: `env eval ...`
+ // arrives through env's utility operand and `bash -c 'eval ...'` localized at
+ // `bash`, so an argument merely named `eval` stays published.
+ if (executesHere && SHELL_STATE_WORDS.has(unquoted)) return true;
+ // Executable-MIME data URLs are inline modules even when a runner subcommand
+ // prevents interpreter option tracking from reaching them.
+ if (/^data:[^,]*(?:javascript|ecmascript|typescript)/i.test(unquoted)) return true;
+ if (pendingFindPrimaries && FIND_EXEC_PRIMARY.test(unquoted)) return true;
+ if (pendingSystemdRunWorkingDirectory) {
+ pendingSystemdRunWorkingDirectory = false;
+ const directory = resolveKnownDirectory(unquoted, trackedCwd);
+ if (directory !== null && isUnderCollectedRoot(directory, rootPrefixes)) return true;
+ } else if (pendingSystemdRunOptions) {
+ const directory = /^--working-directory=(.*)$/.exec(unquoted)?.[1];
+ if (directory !== undefined) {
+ const resolved = resolveKnownDirectory(directory, trackedCwd);
+ if (resolved !== null && isUnderCollectedRoot(resolved, rootPrefixes)) return true;
+ } else if (unquoted === "--working-directory") {
+ pendingSystemdRunWorkingDirectory = true;
+ }
+ }
+ let executableWord = unquoted;
+ if (pendingStartStopDaemonExecutable) {
+ pendingStartStopDaemonExecutable = false;
+ executableWord = unquoted;
+ executesHere = true;
+ } else if (pendingStartStopDaemonOptions) {
+ const attachedExecutable = /^(?:--(?:exec|startas)=|-[xa])(.+)$/.exec(unquoted)?.[1];
+ if (attachedExecutable !== undefined) {
+ executableWord = attachedExecutable;
+ executesHere = true;
+ } else if (/^(?:-x|-a|--exec|--startas)$/.test(unquoted)) {
+ pendingStartStopDaemonExecutable = true;
+ }
+ }
+ const executable = executableWord
+ .slice(Math.max(executableWord.lastIndexOf("/"), executableWord.lastIndexOf("\\")) + 1)
+ .toLowerCase()
+ .replace(/\.exe$/, "");
+ if (executesHere) {
+ commandWordSeen = true;
+ // A directly executed auto-published document runs through its shebang,
+ // publishing an executable relationship no marker can rehydrate elsewhere.
+ if (isShellResolvedPublishedOperand(executableWord)) return true;
+ // A bare name resolves through the inherited PATH, so an entry inside the
+ // collected root reaches the same documents without spelling the root.
+ if (!/[/\\]/.test(executableWord)) {
+ for (const dir of inherited.publishedPathDirs) {
+ if (isAutoPublishedScriptOperand(`${dir}/${executableWord}`, rootPrefixes)) return true;
+ }
+ }
+ if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
+ if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
+ if (inherited.javaLaunchOptionsHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) {
+ return true;
+ }
+ // A published sys.path or class-path archive executes through the plain
+ // launcher (`python3 -m leak`, `java Leak`) without spelling the root.
+ if (inherited.pythonPathHook && PYTHON_LAUNCHER_NAME.test(executable)) return true;
+ if (inherited.javaClassPathHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) return true;
+ if (inherited.luaStartupHook && LUA_LAUNCHER_NAME.test(executable)) return true;
+ if (
+ inherited.perlDebuggerHook &&
+ inherited.perlDebuggerEnvHook &&
+ PERL_LAUNCHER_NAME.test(executable)
+ ) {
+ return true;
+ }
+ // The dynamic loader injects an inherited published preload into every
+ // dynamically linked launcher, ahead of whatever the command runs.
+ if (inherited.loaderPreloadHook) return true;
+ if (inherited.gitConfigHook && executable === "git") return true;
+ if (inherited.opensslConfigHook && executable === "openssl") return true;
+ if (inherited.cmakeToolchainHook && executable === "cmake") return true;
+ if (inherited.makefilesHook && /^(?:g?make|mingw(?:32|64)-make)$/.test(executable)) {
+ return true;
+ }
+ if (executable === "env") sawEnv = true;
+ if (executable === "npm") pendingNpmSubcommand = true;
+ if (executable === "uv") pendingUvSubcommand = true;
+ if (executable === "mise") pendingMiseSubcommand = true;
+ if (executable === "git") pendingGitSubcommand = true;
+ if (executable === "deno") pendingDenoSubcommand = true;
+ if (/^sqlite3[0-9.]*$/.test(executable)) pendingSqliteOptions = true;
+ if (executable === "openssl") pendingOpensslOptions = true;
+ if (/^(?:ld\.so|ld-(?:linux|musl)[^/]*\.so)(?:\.[0-9]+)*$/.test(executable)) {
+ pendingLoaderOptions = true;
+ }
+ if (/^(?:.*-)?clang(?:\+\+)?(?:-[0-9.]+)?$/.test(executable)) pendingClangOptions = true;
+ if (/^lldb(?:-[0-9.]+)?$/.test(executable)) pendingLldbOptions = true;
+ if (/^(?:.*-)?gdb(?:-multiarch)?(?:-[0-9.]+)?$/.test(executable)) pendingGdbOptions = true;
+ if (/^ninja(?:-build)?$/.test(executable)) pendingNinjaOptions = true;
+ if (/^g?tar$/.test(executable)) pendingTarOptions = true;
+ if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
+ if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = true;
+ if (executable === "systemd-run") pendingSystemdRunOptions = true;
+ // Builtins, so matched on the quote-removed word like the state words above.
+ if (unquoted === "hash") pendingHashOptions = true;
+ if (unquoted === "cd" || unquoted === "pushd") pendingCdBuiltin = unquoted;
+ if (FIND_EXECUTABLE_NAMES.has(executable)) pendingFindPrimaries = true;
+ const carrierSkips = COMMAND_CARRIER_OPERANDS.get(executable);
+ if (carrierSkips === -1) {
+ carrierSticky = true;
+ } else if (carrierSkips !== undefined) {
+ carrierArmed = true;
+ carrierOperandSkips = carrierSkips;
+ }
+ if (SHELL_INTERPRETER_NAMES.has(executable)) return true;
+ if (PROGRAM_OPERAND_INTERPRETER_NAMES.has(executable)) return true;
+ if (SHELL_REPARSE_EXECUTABLE_NAMES.has(executable)) return true;
+ }
+ // Attached/separate R/PHP file options name the same script boundary as a
+ // positional operand, but their leading dash would otherwise look merely
+ // ambiguous. Either form ends tracking so later script arguments are not mistaken
+ // for code; an automatically published script localizes first.
+ let attachedScriptBoundary = false;
+ let workingDirectoryOptionMatched = false;
+ for (const pending of pendingLanguages) {
+ const workingDirectory = pending.workingDirectoryOption?.exec(unquoted)?.[1];
+ if (workingDirectory !== undefined) {
+ if (workingDirectory === "") {
+ pendingLanguageWorkingDirectory = pending;
+ } else {
+ const resolved = resolveKnownDirectory(workingDirectory, trackedCwd);
+ if (resolved === null) languageWorkingDirectories.delete(pending);
+ else languageWorkingDirectories.set(pending, resolved);
+ }
+ workingDirectoryOptionMatched = true;
+ break;
+ }
+ const startup = pending.attachedStartupFile?.exec(unquoted)?.[1];
+ if (startup !== undefined && isShellResolvedPublishedOperand(startup)) {
+ return true;
+ }
+ const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
+ if (script !== undefined) {
+ if (isPendingLanguageScriptOperand(script)) return true;
+ attachedScriptBoundary = true;
+ break;
+ }
+ if (pending.pathScriptFileOption?.test(unquoted) === true) {
+ pendingScriptFileOperand = true;
+ pendingScriptFileUsesPath = true;
+ } else if (pending.separateScriptFileOption?.test(unquoted) === true) {
+ pendingScriptFileOperand = true;
+ pendingScriptFileUsesPath = false;
+ }
+ // An inherited startup hook naming a published document executes on any
+ // interactive spelling before the first prompt.
+ if (inherited.pythonStartupHook && pending.interactiveOption?.test(unquoted) === true) {
+ return true;
+ }
+ if (inherited.perlDebuggerHook && pending.debuggerOption?.test(unquoted) === true) {
+ return true;
+ }
+ // An evaluation word after a language interpreter hands that grammar a script.
+ if (pending.evalWord?.test(unquoted) === true) return true;
+ }
+ if (workingDirectoryOptionMatched) continue;
+ if (attachedScriptBoundary) {
+ commandConsumesStdin = false;
+ clearInterpreterTracking();
+ }
+
+ const language = executesHere
+ ? LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable))
+ : undefined;
+ if (language) {
+ // Without a script operand these interpreters execute standard input, so a
+ // published document already redirected into this command localizes here.
+ commandConsumesStdin = true;
+ if (commandPublishedStdin) return true;
+ pendingLanguages.add(language);
+ evalOperandAmbiguous = false;
+ } else if (pendingLanguages.size > 0) {
+ if (unquoted.startsWith("-")) {
+ // An interpreter option may take a separate argument this scan cannot pair
+ // (`python3 -W ignore -c x`), so from here a non-option word no longer
+ // proves the script boundary; tracking stays armed, failing closed.
+ evalOperandAmbiguous = true;
+ } else if (isPendingLanguageScriptOperand(unquoted)) {
+ // The backup publishes this document automatically. An interpreter executing
+ // it can join credential fragments across the command and file even when
+ // neither spelling matches the non-overridable token backstop.
+ return true;
+ } else if (!evalOperandAmbiguous) {
+ // The first non-option word no pending pattern matched is the script/module
+ // operand: later dash-led words and stdin belong to that program (`python3
+ // server.py -c settings.toml` hands -c to server.py), so eval tracking ends
+ // here and the file launchers this table intends to preserve stay portable.
+ commandConsumesStdin = false;
+ clearInterpreterTracking();
+ }
+ }
+ // A quoted region spanning whitespace is a script or argument string some
+ // interpreter re-parses on its own terms (`sh -c '...'`, `powershell -Command
+ // '$env:TOKEN=...; ...'`, `csh -c 'setenv TOKEN ...'`, `env -S'...'`); what that
+ // grammar treats as an assignment is not decidable here.
+ if (/\s/.test(unquoted)) return true;
+ if (!word.includes("=")) continue;
+ if (envOperandsOnly) return true;
+ // GNU `env` reads a bare `=value` word as an assignment operand too.
+ if (unquoted.startsWith("=")) return true;
+ // A quote-mangled `NAME=` spelling (`TOKEN\\=x`, `'TOKEN'=x`) for `env`/`eval`.
+ if (ASSIGNMENT_START.test(unquoted)) return true;
+ // An option value can embed a whole assignment for the target program
+ // (`systemd-run --setenv=TOKEN=x`, `--env=TOKEN=x`): a second `=` past the
+ // option's own separator marks one. Plain long-option flag values
+ // (`--transport=stdio`) carry no inner `=` and stay published.
+ if (
+ unquoted.startsWith("-") &&
+ ASSIGNMENT_START.test(unquoted.slice(unquoted.indexOf("=") + 1))
+ ) {
+ return true;
+ }
+ // A short option with an attached argument leaves no boundary before the
+ // assignment (`systemd-run -ETOKEN=x`, `-Dapi.key=x`), and which letters take
+ // env-like arguments is per-program knowledge this scan cannot have.
+ if (/^-[^-]/.test(unquoted)) return true;
+ // `=` mixed with quoting or expansion machinery: some other grammar's assignment
+ // (`$env:TOKEN=x`, `python -c 'os.environ["TOKEN"]="x"'` fragments).
+ if (/['"\\$]/.test(word)) return true;
+ }
+ return false;
+}
+
+/**
+ * The undecidable constructs, detected only where the shell parses them. An expansion
+ * body can carry arbitrary bytes into one runtime word (`TOKEN$(printf =hunter2)`,
+ * `$'TOKEN\x3d...'`, legacy arithmetic `TOKEN$[0]=...`), and every parameter
+ * expansion depends on execution state the words cannot show: the command itself can
+ * fill `$1` (`set -- p`) or a plain `$X` with no assignment word to rewrite
+ * (`for X in p`, `printf -v X p`), so `gh$X'_'...` runs as a contiguous credential
+ * no scan of the spelling reconstructs. Any of them makes assignment detection
+ * undecidable. A
+ * here-document or here-string feeds the consumer a body under document rules the word
+ * scans would misread. Process substitution and write redirection each hand the
+ * consumer a file whose bytes the command chooses (`--token-file <(printf a;printf b)`,
+ * `printf a >f; printf b >>f`), assignment or not, so both localize; program-internal
+ * writes (`tee`) are per-program knowledge no shell-syntax scan can model, the same
+ * boundary drawn for option semantics. A pipe moves one stage's bytes into the next
+ * (`printf a | { read -r T; export T; ... }`), so pipes localize too, while `||` and
+ * `&&` carry no data and stay portable. With `extglob` inherited via BASHOPTS, `?( *( +( @( !(` open one
+ * pathname pattern whose file match can complete a credential, undecidable like any
+ * glob. Single-quoted, escaped, and commented spellings are inert
+ * (`--pattern '$(date)'` is a literal argument a raw-string test would localize), while
+ * double quotes keep expansions live but make redirections literal.
+ */
+function findActiveShellConstructs(command: string): {
+ carrier: boolean;
+ heredoc: boolean;
+ processSubstitution: boolean;
+ redirection: boolean;
+ pipeline: boolean;
+} {
+ const found = {
+ carrier: false,
+ heredoc: false,
+ processSubstitution: false,
+ redirection: false,
+ pipeline: false,
+ };
+ let i = 0;
+ let wordStart = true;
+ // The previous character as Bash sees it, or "" when that character was quoted or
+ // escaped: extglob operators only form from two adjacent unquoted characters.
+ let prevActive = "";
+ while (i < command.length) {
+ const char = command[i];
+ if (char === "\\") {
+ // A backslash-LF continuation vanishes before tokenization, so it neither opens
+ // nor ends a word: a `#` right after `cmd \` still sits at a comment position.
+ if (command[i + 1] !== "\n") {
+ wordStart = false;
+ prevActive = "";
+ }
+ i += 2;
+ continue;
+ }
+ if (char === "'") {
+ const end = command.indexOf("'", i + 1);
+ i = end === -1 ? command.length : end + 1;
+ wordStart = false;
+ prevActive = "";
+ continue;
+ }
+ if (char === '"') {
+ let j = i + 1;
+ while (j < command.length && command[j] !== '"') {
+ if (command[j] === "\\") {
+ j += 2;
+ continue;
+ }
+ if (command[j] === "`") found.carrier = true;
+ if (command[j] === "$" && /[({[!0-9@*#?$A-Za-z_-]/.test(command[j + 1] ?? "")) {
+ found.carrier = true;
+ }
+ j += 1;
+ }
+ i = j + 1;
+ wordStart = false;
+ prevActive = "";
+ continue;
+ }
+ if (char === "#" && wordStart) {
+ const lineEnd = command.indexOf("\n", i);
+ if (lineEnd === -1) break;
+ i = lineEnd + 1;
+ wordStart = true;
+ prevActive = "";
+ continue;
+ }
+ if (char === "`") {
+ found.carrier = true;
+ i += 1;
+ wordStart = false;
+ prevActive = char;
+ continue;
+ }
+ if (char === "$") {
+ if (/[({['"!0-9@*#?$A-Za-z_-]/.test(command[i + 1] ?? "")) found.carrier = true;
+ i += 1;
+ wordStart = false;
+ prevActive = char;
+ continue;
+ }
+ if (char === "<") {
+ if (command[i + 1] === "<") found.heredoc = true;
+ if (command[i + 1] === "(") found.processSubstitution = true;
+ i += 1;
+ wordStart = true;
+ prevActive = char;
+ continue;
+ }
+ if (char === ">") {
+ if (command[i + 1] === "(") found.processSubstitution = true;
+ // Any write redirection lets the command assemble a file whose bytes the scans
+ // cannot model (`printf a >f; printf b >>f; mcp --token-file f`).
+ found.redirection = true;
+ i += 1;
+ wordStart = true;
+ prevActive = char;
+ continue;
+ }
+ if (char === "|") {
+ // `||` is a control operator with no data flow, but a pipe (`|`, `|&`) hands one
+ // stage's bytes to the next, where `read` can turn published fragments into an
+ // exported variable.
+ if (command[i + 1] === "|") {
+ i += 2;
+ wordStart = true;
+ prevActive = char;
+ continue;
+ }
+ found.pipeline = true;
+ i += 1;
+ wordStart = true;
+ prevActive = char;
+ continue;
+ }
+ if (char === "(" && prevActive !== "" && "?*+@!".includes(prevActive)) {
+ found.carrier = true;
+ }
+ wordStart = char === " " || char === "\t" || char === "\n" || SHELL_WORD_BREAK.includes(char);
+ prevActive = char;
+ i += 1;
+ }
+ return found;
+}
+
+/**
+ * The command split at Bash comment boundaries, quote-aware: assignment-like prose in a
+ * comment must neither be rewritten (Bash never evaluates it, and a marker would make
+ * the whole command machine-local) nor feed the residue checks. Each piece keeps its
+ * trailing comment, and the newline that ends a comment stays in the next piece's code,
+ * so per-piece replacement sees the same boundaries the one-string form did.
+ */
+function splitCommandComments(command: string): Array<{ code: string; comment: string }> {
+ const pieces: Array<{ code: string; comment: string }> = [];
+ let code = "";
+ let i = 0;
+ let wordStart = true;
+ while (i < command.length) {
+ const char = command[i];
+ if (char === "#" && wordStart) {
+ const lineEnd = command.indexOf("\n", i);
+ const end = lineEnd === -1 ? command.length : lineEnd;
+ pieces.push({ code, comment: command.slice(i, end) });
+ code = "";
+ i = end;
+ wordStart = true;
+ continue;
+ }
+ if (char === "\\") {
+ code += command.slice(i, i + 2);
+ // Invisible to tokenization, a continuation keeps the comment position open.
+ if (command[i + 1] !== "\n") wordStart = false;
+ i += 2;
+ continue;
+ }
+ if (char === "'" || char === '"') {
+ const quote = char;
+ let j = i + 1;
+ while (j < command.length && command[j] !== quote) {
+ j += quote === '"' && command[j] === "\\" ? 2 : 1;
+ }
+ code += command.slice(i, Math.min(j + 1, command.length));
+ i = j + 1;
+ wordStart = false;
+ continue;
+ }
+ code += char;
+ wordStart = char === " " || char === "\t" || char === "\n" || SHELL_WORD_BREAK.includes(char);
+ i += 1;
+ }
+ pieces.push({ code, comment: "" });
+ return pieces;
+}
+
+/**
+ * The command with every active line continuation removed. Bash deletes an unquoted or
+ * double-quoted backslash-LF before any expansion, so syntax split across one
+ * (`$`+continuation+`(`, a brace sequence's `..`) reads contiguously to the shell
+ * while a per-character analyzer would see an escape pair. Single-quoted pairs stay the
+ * literal bytes the process receives, and a comment's backslash is prose that cannot
+ * hide the newline ending the comment.
+ */
+function removeActiveLineContinuations(command: string): string {
+ let result = "";
+ let i = 0;
+ let wordStart = true;
+ while (i < command.length) {
+ const char = command[i];
+ if (char === "\\") {
+ if (command[i + 1] === "\n") {
+ i += 2;
+ continue;
+ }
+ result += command.slice(i, i + 2);
+ i += 2;
+ wordStart = false;
+ continue;
+ }
+ if (char === "'") {
+ const end = command.indexOf("'", i + 1);
+ const stop = end === -1 ? command.length : end + 1;
+ result += command.slice(i, stop);
+ i = stop;
+ wordStart = false;
+ continue;
+ }
+ if (char === '"') {
+ result += char;
+ let j = i + 1;
+ while (j < command.length && command[j] !== '"') {
+ if (command[j] === "\\" && command[j + 1] === "\n") {
+ j += 2;
+ continue;
+ }
+ if (command[j] === "\\") {
+ result += command.slice(j, j + 2);
+ j += 2;
+ continue;
+ }
+ result += command[j];
+ j += 1;
+ }
+ if (j < command.length) result += '"';
+ i = j + 1;
+ wordStart = false;
+ continue;
+ }
+ if (char === "#" && wordStart) {
+ const lineEnd = command.indexOf("\n", i);
+ const end = lineEnd === -1 ? command.length : lineEnd;
+ result += command.slice(i, end);
+ i = end;
+ continue;
+ }
+ result += char;
+ wordStart = char === " " || char === "\t" || char === "\n" || SHELL_WORD_BREAK.includes(char);
+ i += 1;
+ }
+ return result;
+}
+
+/**
+ * Fail closed before any per-character analysis: mcp.jsonc may be megabytes, and the
+ * walks below hold per-character state (projection copies, brace stacks), so an
+ * adversarial brace wall could stall the synchronous main process for seconds and
+ * balloon memory. No legitimate portable command approaches this length; beyond it the
+ * command goes machine-local without being parsed at all.
+ */
+export const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
+
+/**
+ * The per-command cap composes: a near-8 MB mcp.jsonc can hold ~250 commands that each
+ * pass it, and their walks together still stall the synchronous main process for
+ * seconds. One aggregate budget per config bounds total analysis work; commands past
+ * it go machine-local unparsed, exactly like a single oversized command.
+ */
+export const MAX_TOTAL_ANALYZED_COMMAND_LENGTH = 8 * MAX_ANALYZED_COMMAND_LENGTH;
+
+function redactCommandEnvAssignments(
+ command: string,
+ rootPrefixes: readonly string[],
+ inherited: InheritedLaunchContext
+): string {
+ if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
+ // Analysis mirrors execution: active continuations vanish first, so every analyzer
+ // below sees the same contiguous syntax the shell parses.
+ const analyzed = removeActiveLineContinuations(command);
+ const pieces = splitCommandComments(analyzed);
+ const redactedPieces = pieces.map((piece) =>
+ piece.code.replace(
+ COMMAND_ENV_ASSIGNMENT,
+ (_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
+ )
+ );
+ const redactedCode = redactedPieces.join("");
+ // When an assignment's boundaries cannot be trusted, no partial rewrite can be either,
+ // so the whole command goes local and restore puts the exact text back. The residue and
+ // quote-led checks run even when nothing was replaced: an unconsumable or quote-led
+ // value means the replacement never saw it.
+ const constructs = findActiveShellConstructs(analyzed);
+ if (
+ UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
+ hasDisguisedAssignment(redactedCode, rootPrefixes, inherited) ||
+ constructs.carrier ||
+ constructs.heredoc ||
+ constructs.processSubstitution ||
+ constructs.redirection ||
+ constructs.pipeline
+ ) {
+ return REDACTED_BACKUP_VALUE;
+ }
+ const rewritten = redactedPieces
+ .map((piece, index) => piece + (pieces[index]?.comment ?? ""))
+ .join("");
+ // Nothing to redact: the original spelling, wrapped lines and all, is what executes.
+ if (rewritten === analyzed) return command;
+ // Markers are positioned in the unwrapped spelling; when the original wrapped lines,
+ // mapping them back onto the wrapped text is not decidable, so the command goes
+ // machine-local instead of publishing a respelled value.
+ return analyzed === command ? rewritten : REDACTED_BACKUP_VALUE;
+}
+
+/**
+ * Non-interactive Bash consults inherited startup state before parsing its `-c`
+ * command: a non-empty `BASH_ENV` names a file it sources first, and `BASH_FUNC_*`
+ * environment entries import exported functions. Either hook can redefine any command
+ * word (`mcp(){ mcp --token "$2$3"; }`), so the word-level semantics every command
+ * analyzer above relies on stop binding. The stdio launch inherits this process's
+ * environment (mcpServerManager passes commands to `runtime.exec`, a `bash -c`), so
+ * while a hook is present commands go machine-local without being analyzed at all.
+ * An empty `BASH_ENV` sources nothing and is inert. Only the inherited process
+ * environment gates this: a server entry's own `env` field is dropped by
+ * `McpConfigService.normalizeEntry` before spawn, so it must not localize an otherwise
+ * portable command (a fresh-device restore would drop the whole server over a field
+ * the runtime never reads). This covers only startup hooks visible in the exporting
+ * process environment: off-host runtime startup state (container images, remote SSH
+ * hosts) is not observable to settings backup export, and treating it as an input
+ * would force localizing every command; neutralizing those shells belongs to the
+ * runtime spawn paths.
+ */
+function isBashStartupHookVariable(name: string, value: unknown): boolean {
+ if (name.startsWith("BASH_FUNC_")) return true;
+ return name === "BASH_ENV" && value !== "" && value !== undefined;
+}
+
+/**
+ * Ambient facts from the exporting process environment that the spawned server
+ * inherits (see isBashStartupHookVariable for the channel).
*/
-function applyJsoncEdits(text: string, edits: Array<{ path: jsonc.JSONPath; value: unknown }>) {
- let result = text;
- for (const edit of edits) {
- result = jsonc.applyEdits(
- result,
- jsonc.modify(result, edit.path, edit.value, JSONC_EDIT_OPTIONS)
- );
+interface InheritedLaunchContext {
+ /** PATH entries resolving into the collected root. */
+ publishedPathDirs: readonly string[];
+ /** CDPATH entries Bash searches before a relative cd target. */
+ cdPathDirs: readonly string[];
+ /** NODE_OPTIONS carries an executable preload/import option. */
+ nodeCodeOptions: boolean;
+ /** PYTHONSTARTUP names an auto-published document. */
+ pythonStartupHook: boolean;
+ /** A PYTHONPATH entry names an auto-published archive Python imports from. */
+ pythonPathHook: boolean;
+ /** The inherited CLASSPATH names an auto-published executable archive. */
+ javaClassPathHook: boolean;
+ /** PHPRC names an auto-published configuration document. */
+ phpConfigHook: boolean;
+ /** A JVM option variable names an auto-published agent or boot-class-path archive. */
+ javaLaunchOptionsHook: boolean;
+ /** A LUA_INIT variable's @file form names an auto-published document. */
+ luaStartupHook: boolean;
+ /** An inherited dynamic-loader preload list names an auto-published document. */
+ loaderPreloadHook: boolean;
+ /** Inherited Git config overrides name a sensitive key or a published file. */
+ gitConfigHook: boolean;
+ /** OPENSSL_CONF names an auto-published configuration document. */
+ opensslConfigHook: boolean;
+ /** A non-empty PERL5DB program runs when Perl's debugger is enabled. */
+ perlDebuggerHook: boolean;
+ /** PERL5OPT enables the debugger before command-line option parsing. */
+ perlDebuggerEnvHook: boolean;
+ /** CMAKE_TOOLCHAIN_FILE names an auto-published toolchain script. */
+ cmakeToolchainHook: boolean;
+ /** MAKEFILES includes an auto-published makefile before the normal inputs. */
+ makefilesHook: boolean;
+}
+
+/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import/config input. */
+function hasInheritedNodeCodeOptions(value: unknown, rootPrefixes: readonly string[]): boolean {
+ if (typeof value !== "string" || value === "") return false;
+ let sharedOpenSslConfig = false;
+ let publishedOpenSslConfig = false;
+ let pendingOpenSslConfig = false;
+ for (const match of value.matchAll(SHELL_WORD)) {
+ const option = unquoteShellWord(match[0]);
+ if (pendingOpenSslConfig) {
+ pendingOpenSslConfig = false;
+ publishedOpenSslConfig ||= isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(option),
+ rootPrefixes
+ );
+ continue;
+ }
+ if (/^(?:-r(?:.*)|--(?:require|import|loader|experimental-loader)(?:=|$))/.test(option)) {
+ return true;
+ }
+ if (option === "--openssl-shared-config") sharedOpenSslConfig = true;
+ const config = /^--openssl-config=(.+)$/.exec(option)?.[1];
+ if (config !== undefined) {
+ publishedOpenSslConfig ||= isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(config),
+ rootPrefixes
+ );
+ } else if (option === "--openssl-config") {
+ pendingOpenSslConfig = true;
+ }
}
- return result;
+ return sharedOpenSslConfig && publishedOpenSslConfig;
}
-interface JsoncPropertyInsertion {
- leadingText: string;
- propertyText: string;
- trailingCommentText: string;
+function hasInheritedPerlDebuggerOption(value: unknown): boolean {
+ if (typeof value !== "string") return false;
+ return [...value.matchAll(SHELL_WORD)].some((match) =>
+ /^-d(?:$|[:t])/.test(unquoteShellWord(match[0]))
+ );
}
-type LocalMcpServerMerge =
- | { kind: "none" }
- | { kind: "replace"; valueText: string }
- | {
- kind: "insert";
- objectPath: jsonc.JSONPath;
- entries: JsoncPropertyInsertion[];
- objectTrailingText: string;
- };
-
-function containsJsoncComma(text: string): boolean {
- const scanner = jsonc.createScanner(text, false);
- for (let token = scanner.scan(); token !== jsonc.SyntaxKind.EOF; token = scanner.scan()) {
- if (token === jsonc.SyntaxKind.CommaToken) return true;
+/**
+ * JVM option variables inject execution into every launched JVM: agents,
+ * class/module paths, module patches, boot paths, and argument files can all
+ * supply executable bytecode before the application starts.
+ */
+function hasInheritedJavaLaunchOptions(
+ values: readonly unknown[],
+ rootPrefixes: readonly string[]
+): boolean {
+ for (const value of values) {
+ if (typeof value !== "string") continue;
+ let pendingPathOption: "path" | "patch" | null = null;
+ for (const match of value.matchAll(SHELL_WORD)) {
+ const option = unquoteShellWord(match[0]);
+ if (pendingPathOption !== null) {
+ const publishes =
+ pendingPathOption === "patch"
+ ? javaPatchModulePublishesExecutable(option, rootPrefixes, null)
+ : javaClassPathPublishesExecutable(option, rootPrefixes, null);
+ pendingPathOption = null;
+ if (publishes) return true;
+ continue;
+ }
+ if (
+ option.startsWith("@") &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(option.slice(1)), rootPrefixes)
+ ) {
+ return true;
+ }
+ const agent = /^-(?:javaagent|agentpath):([^=]+)/.exec(option)?.[1];
+ if (
+ agent !== undefined &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(agent), rootPrefixes)
+ ) {
+ return true;
+ }
+ const bootClassPath = /^-Xbootclasspath(?:\/[ap])?:(.+)$/.exec(option)?.[1];
+ if (
+ bootClassPath !== undefined &&
+ javaClassPathPublishesExecutable(bootClassPath, rootPrefixes, null)
+ ) {
+ return true;
+ }
+ const pathOption = /^--(?:class-path|module-path|upgrade-module-path)=(.+)$/.exec(
+ option
+ )?.[1];
+ if (
+ pathOption !== undefined &&
+ javaClassPathPublishesExecutable(pathOption, rootPrefixes, null)
+ ) {
+ return true;
+ }
+ const patchModule = /^--patch-module=(.+)$/.exec(option)?.[1];
+ if (
+ patchModule !== undefined &&
+ javaPatchModulePublishesExecutable(patchModule, rootPrefixes, null)
+ ) {
+ return true;
+ }
+ if (
+ isJavaClassPathOption(option) ||
+ /^(?:-p|--module-path|--upgrade-module-path)$/.test(option)
+ ) {
+ pendingPathOption = "path";
+ } else if (option === "--patch-module") {
+ pendingPathOption = "patch";
+ }
+ }
}
return false;
}
-function lineIndentAt(text: string, offset: number): string {
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
- const prefix = text.slice(lineStart, offset);
- return /^[\t ]*$/.test(prefix) ? prefix : "";
-}
-
-function insertJsoncObjectProperties(
- text: string,
- jsonPath: jsonc.JSONPath,
- entries: readonly JsoncPropertyInsertion[],
- objectTrailingText: string
-): string {
- if (entries.length === 0) return text;
- const tree = jsonc.parseTree(text);
- const objectNode = tree ? jsonc.findNodeAtLocation(tree, jsonPath) : undefined;
- if (objectNode?.type !== "object") throw new Error("Invalid mcp.jsonc");
-
- const properties = objectNode.children ?? [];
- const lastProperty = properties.at(-1);
- const objectEnd = objectNode.offset + objectNode.length - 1;
- const trailingComma =
- lastProperty !== undefined &&
- containsJsoncComma(text.slice(lastProperty.offset + lastProperty.length, objectEnd));
- const objectProperty = objectNode.parent?.type === "property" ? objectNode.parent : undefined;
- const closingIndent = lineIndentAt(text, objectProperty?.offset ?? objectNode.offset);
- const propertyIndent = `${closingIndent}${" ".repeat(JSONC_FORMATTING_OPTIONS.tabSize ?? 2)}`;
- const eol = text.includes("\r\n") ? "\r\n" : "\n";
- const entryText = entries
- .map((entry, index) => {
- const leadingText = entry.leadingText || `${eol}${propertyIndent}`;
- const comma = index < entries.length - 1 || trailingComma ? "," : "";
- const trailingComment =
- entry.trailingCommentText === "" ? "" : ` ${entry.trailingCommentText}`;
- return `${leadingText}${entry.propertyText}${comma}${trailingComment}`;
- })
- .join("");
- const closeLineStart = text.lastIndexOf("\n", objectEnd - 1) + 1;
- const closePrefix = text.slice(closeLineStart, objectEnd);
- const insertAtLineStart = /^[\t ]*$/.test(closePrefix);
- const insertionOffset = insertAtLineStart ? closeLineStart : objectEnd;
- const insertedContent = `${entryText}${objectTrailingText}`;
- const insertionText = insertAtLineStart
- ? `${insertedContent.replace(/^\r?\n/, "")}${eol}`
- : `${insertedContent.startsWith(eol) ? "" : eol}${insertedContent}${eol}${closingIndent}`;
-
- let result = jsonc.applyEdits(text, [
- { offset: insertionOffset, length: 0, content: insertionText },
- ]);
- if (lastProperty !== undefined && !trailingComma) {
- result = jsonc.applyEdits(result, [
- { offset: lastProperty.offset + lastProperty.length, length: 0, content: "," },
- ]);
+function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
+ for (const [name, value] of Object.entries(process.env)) {
+ // Lua runs LUA_INIT (and per-version LUA_INIT_5_4 spellings) at startup; the
+ // @ prefix names a file, and any other value is inline code, not a document.
+ if (!/^LUA_INIT(?:_\d+_\d+)?$/.test(name)) continue;
+ if (typeof value !== "string" || !value.startsWith("@")) continue;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(value.slice(1)), rootPrefixes)) {
+ return true;
+ }
}
- return result;
+ return false;
}
-function replaceJsoncNodeText(text: string, jsonPath: jsonc.JSONPath, valueText: string): string {
- const tree = jsonc.parseTree(text);
- const node = tree ? jsonc.findNodeAtLocation(tree, jsonPath) : undefined;
- if (!node) throw new Error("Invalid mcp.jsonc");
- return jsonc.applyEdits(text, [{ offset: node.offset, length: node.length, content: valueText }]);
+function loaderListPublishesExecutable(
+ value: string,
+ rootPrefixes: readonly string[],
+ currentDirectory: string | null,
+ searchDirs: readonly string[]
+): boolean {
+ for (const entry of value.split(/[:\s]+/)) {
+ if (entry === "") continue;
+ if (/[/\\]/.test(entry)) {
+ if (isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ const resolved = resolveKnownDirectory(entry, currentDirectory);
+ if (resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes)) return true;
+ } else {
+ for (const directory of searchDirs) {
+ if (isAutoPublishedScriptOperand(`${directory}/${entry}`, rootPrefixes)) return true;
+ }
+ }
+ }
+ return false;
}
/**
- * `McpConfigService.readConfigFile` enumerates `servers` with `Object.entries`, so an array or
- * a string there becomes runnable servers named by index rather than being ignored. A document
- * like that cannot be projected field by field, so both an export and a restore refuse it
- * instead of passing a shape the runtime accepts through unexamined.
- * A falsy value is not this case: the runtime returns no servers at all for it.
+ * The dynamic loader runs inherited preload/audit objects inside every
+ * dynamically linked launcher before the command, and accepts a shared object
+ * regardless of filename suffix. glibc splits its lists on colons or spaces;
+ * dyld's DYLD_INSERT_LIBRARIES is colon-separated, preserving spaced paths.
+ * A slashless entry is not a pathname: the loader resolves it through the
+ * inherited library search path before the default directories.
*/
-function isUnsupportedServerMap(value: unknown): boolean {
- return Boolean(value) && (typeof value !== "object" || Array.isArray(value));
+function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
+ const linuxSearchDirs = (process.env.LD_LIBRARY_PATH ?? "")
+ .split(/[:;]/)
+ .map(canonicalizeInheritedPath);
+ const dyldSearchDirs = [
+ ...(process.env.DYLD_LIBRARY_PATH ?? "").split(":"),
+ ...(process.env.DYLD_FALLBACK_LIBRARY_PATH ?? "").split(":"),
+ ].map(canonicalizeInheritedPath);
+ const preloadLists: ReadonlyArray = [
+ [process.env.LD_PRELOAD, /[:\s]+/, linuxSearchDirs],
+ [process.env.LD_AUDIT, /[:\s]+/, linuxSearchDirs],
+ [process.env.DYLD_INSERT_LIBRARIES, /:/, dyldSearchDirs],
+ ];
+ for (const [value, delimiter, searchDirs] of preloadLists) {
+ if (typeof value !== "string") continue;
+ for (const entry of value.split(delimiter)) {
+ if (entry === "") continue;
+ if (/[/\\]/.test(entry)) {
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)) {
+ return true;
+ }
+ } else {
+ for (const dir of searchDirs) {
+ if (
+ dir !== "" &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(`${dir}/${entry}`), rootPrefixes)
+ ) {
+ return true;
+ }
+ }
+ }
+ }
+ }
+ return false;
}
/**
- * Fields Xum itself reads (`McpConfigService.normalizeEntry`), with the type it reads them as.
- * Anything else in the document, at any depth, is replaced with the marker: `normalizeEntry`
- * ignores an unrecognised field such as `env` or `args`, so nobody here can say whether its
- * value is a credential, and `{ "API_KEY": "hunter2" }` is not something a scanner can catch.
- * Restore puts the local value back at that exact path, so a field only Xum ignores is not
- * lost from a machine that already has it.
+ * Inherited Git config overrides apply to every git invocation: the
+ * GIT_CONFIG_COUNT/KEY/VALUE family injects command-scope entries, and
+ * GIT_CONFIG_GLOBAL/GIT_CONFIG_SYSTEM replace the files Git reads. A sensitive
+ * key or a published replacement file localizes git launchers.
*/
-const PORTABLE_SERVER_FIELDS: Record boolean> = {
- command: (value) => typeof value === "string",
- url: (value) => typeof value === "string",
- transport: (value) =>
- value === "stdio" || value === "http" || value === "sse" || value === "auto",
- disabled: (value) => typeof value === "boolean",
- toolAllowlist: (value) => Array.isArray(value) && value.every((tool) => typeof tool === "string"),
-};
+function hasInheritedGitConfig(rootPrefixes: readonly string[]): boolean {
+ // This deprecated carrier has a private shell-quoted grammar and takes
+ // precedence over the numbered family. Any non-empty value can inject a
+ // command-valued key, so affected Git launchers fail closed.
+ if ((process.env.GIT_CONFIG_PARAMETERS ?? "").trim() !== "") return true;
+ const count = Number.parseInt(process.env.GIT_CONFIG_COUNT ?? "", 10);
+ if (Number.isFinite(count) && count > 0) {
+ for (const [name, value] of Object.entries(process.env)) {
+ const index = /^GIT_CONFIG_KEY_(\d+)$/.exec(name)?.[1];
+ if (index === undefined || Number.parseInt(index, 10) >= count) continue;
+ if (typeof value === "string" && gitConfigOverrideNamesSensitiveKey(value)) return true;
+ }
+ }
+ for (const file of [
+ process.env.GIT_CONFIG,
+ process.env.GIT_CONFIG_GLOBAL,
+ process.env.GIT_CONFIG_SYSTEM,
+ ]) {
+ if (typeof file !== "string") continue;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(file), rootPrefixes)) return true;
+ }
+ return false;
+}
/**
- * A jsonc edit keeps every comment, and a comment is prose the projection cannot inspect, so a
- * local `// token=hunter2` beside a server would be published verbatim and the scanner would
- * not recognise it either. Reserializing publishes only the values this file kept.
+ * Git also executes commands inherited directly from the environment. Shell
+ * command variables use the same bounded command analyzer as MCP commands;
+ * direct program variables and the helper search directory resolve as paths.
*/
-function serializeProjectedMcp(text: string): {
- content: Buffer;
- parsed: Record;
-} {
- const parsed = readRecord(jsonc.parse(text));
- if (!parsed) throw new Error("Invalid mcp.jsonc");
- return {
- content: Buffer.from(`${JSON.stringify(parsed, null, 2)}\n`, "utf-8"),
- parsed,
- };
-}
-
-function valueHasRedactionAtPath(
- root: Record,
- jsonPath: BackupRedactionPath
+function hasInheritedGitExecutionHook(
+ rootPrefixes: readonly string[],
+ inherited: InheritedLaunchContext
): boolean {
- let value: unknown = root;
- for (const segment of jsonPath) {
- if (typeof segment === "number") {
- value = Array.isArray(value) ? value[segment] : undefined;
- continue;
+ const nestedContext = { ...inherited, gitConfigHook: false };
+ for (const command of [
+ process.env.GIT_SSH_COMMAND,
+ process.env.GIT_PROXY_COMMAND,
+ process.env.GIT_EDITOR,
+ process.env.GIT_SEQUENCE_EDITOR,
+ process.env.GIT_PAGER,
+ process.env.GIT_EXTERNAL_DIFF,
+ process.env.VISUAL,
+ process.env.EDITOR,
+ process.env.PAGER,
+ ]) {
+ if (typeof command !== "string" || command.trim() === "") continue;
+ if (
+ redactCommandEnvAssignments(command, rootPrefixes, nestedContext) === REDACTED_BACKUP_VALUE
+ ) {
+ return true;
}
- const record = readRecord(value);
- value = record ? readOwn(record, segment) : undefined;
}
- return typeof value === "string" && containsRedaction(value);
+ for (const program of [process.env.GIT_SSH, process.env.GIT_ASKPASS, process.env.SSH_ASKPASS]) {
+ if (typeof program !== "string") continue;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(program), rootPrefixes)) return true;
+ }
+ const execPath = process.env.GIT_EXEC_PATH;
+ return (
+ typeof execPath === "string" &&
+ isUnderCollectedRoot(canonicalizeInheritedPath(execPath), rootPrefixes)
+ );
}
-function redactMcpConfig(content: Buffer): {
+function redactMcpConfig(
+ content: Buffer,
+ muxRoot: string
+): {
content: Buffer;
redactionPaths: BackupRedactionPath[];
} {
+ const rootPrefixes = collectedDocumentRootPrefixes(muxRoot);
+ // The stdio launch inherits this process's environment (see
+ // isBashStartupHookVariable), so a PATH entry inside the collected root makes
+ // published executable documents reachable as bare command names.
+ const inherited: InheritedLaunchContext = {
+ publishedPathDirs: (process.env.PATH ?? "")
+ .split(path.delimiter)
+ // A PATH entry can reach the collected root through a symlink, so filter
+ // on the canonical target; joining a bare name against that spelling then
+ // matches the published document it actually resolves to.
+ .map(canonicalizeInheritedPath)
+ .filter((entry) => isUnderCollectedRoot(entry, rootPrefixes)),
+ cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter).map(canonicalizeInheritedPath),
+ nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS, rootPrefixes),
+ pythonStartupHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.PYTHONSTARTUP ?? ""),
+ rootPrefixes
+ ),
+ pythonPathHook: (process.env.PYTHONPATH ?? "")
+ .split(path.delimiter)
+ .some((entry) =>
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ),
+ javaClassPathHook: (process.env.CLASSPATH ?? "")
+ .split(path.delimiter)
+ .some((entry) =>
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ),
+ phpConfigHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.PHPRC ?? ""),
+ rootPrefixes
+ ),
+ javaLaunchOptionsHook: hasInheritedJavaLaunchOptions(
+ [process.env.JAVA_TOOL_OPTIONS, process.env._JAVA_OPTIONS, process.env.JDK_JAVA_OPTIONS],
+ rootPrefixes
+ ),
+ luaStartupHook: hasInheritedLuaStartupFile(rootPrefixes),
+ loaderPreloadHook: hasInheritedLoaderPreload(rootPrefixes),
+ gitConfigHook: hasInheritedGitConfig(rootPrefixes),
+ opensslConfigHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.OPENSSL_CONF ?? ""),
+ rootPrefixes
+ ),
+ perlDebuggerHook: (process.env.PERL5DB ?? "").trim() !== "",
+ perlDebuggerEnvHook: hasInheritedPerlDebuggerOption(process.env.PERL5OPT),
+ cmakeToolchainHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.CMAKE_TOOLCHAIN_FILE ?? ""),
+ rootPrefixes
+ ),
+ makefilesHook: (process.env.MAKEFILES ?? "")
+ .split(/\s+/)
+ .some((entry) =>
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ),
+ };
+ inherited.gitConfigHook ||= hasInheritedGitExecutionHook(rootPrefixes, inherited);
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
@@ -1110,6 +4198,29 @@ function redactMcpConfig(content: Buffer): {
function redact(jsonPath: jsonc.JSONPath): void {
edits.push({ path: jsonPath, value: REDACTED_BACKUP_VALUE });
redactionPaths.push([...jsonPath]);
+ // Queue-time, not only in finish(): each queued edit costs a full-document
+ // jsonc.modify pass there, so an over-limit config must be rejected before it can
+ // buy edit-count x document-size synchronous work with a guaranteed-refused payload.
+ assertBackupMcpRedactionCount(redactionPaths.length);
+ }
+
+ let analysisBudget = MAX_TOTAL_ANALYZED_COMMAND_LENGTH;
+ const ambientStartupHooks = Object.entries(process.env).some(([name, value]) =>
+ isBashStartupHookVariable(name, value)
+ );
+
+ function redactCommand(jsonPath: jsonc.JSONPath, command: string): void {
+ let redacted: string;
+ if (ambientStartupHooks || command.length > analysisBudget) {
+ redacted = REDACTED_BACKUP_VALUE;
+ } else {
+ analysisBudget -= command.length;
+ redacted = redactCommandEnvAssignments(command, rootPrefixes, inherited);
+ }
+ if (redacted === command) return;
+ edits.push({ path: jsonPath, value: redacted });
+ redactionPaths.push([...jsonPath]);
+ assertBackupMcpRedactionCount(redactionPaths.length);
}
function finish(): { content: Buffer; redactionPaths: BackupRedactionPath[] } {
@@ -1148,7 +4259,10 @@ function redactMcpConfig(content: Buffer): {
for (const serverName of objectKeyNames(tree, ["servers"])) {
const rawServer = readOwn(serverRecord, serverName);
// A bare string entry is the stdio command itself (`McpConfigService.normalizeEntry`).
- if (typeof rawServer === "string") continue;
+ if (typeof rawServer === "string") {
+ redactCommand(["servers", serverName], rawServer);
+ continue;
+ }
const server = readRecord(rawServer);
if (!server) {
redact(["servers", serverName]);
@@ -1164,7 +4278,17 @@ function redactMcpConfig(content: Buffer): {
if (isPortableField) {
// Read as the wrong type, `normalizeEntry` ignores it, which makes it another place
// to hide a value nobody reads.
- if (!isPortableField(value)) redact(fieldPath);
+ if (!isPortableField(value)) {
+ redact(fieldPath);
+ continue;
+ }
+ if (field === "command" && typeof value === "string") redactCommand(fieldPath, value);
+ // Whole-value, not in-string: the userinfo/parameter detection deliberately covers
+ // malformed and percent-encoded spellings a partial rewrite could misparse and leave
+ // the credential in. Restore puts the local url back at this path.
+ if (field === "url" && typeof value === "string" && urlHasCredentialComponents(value)) {
+ redact(fieldPath);
+ }
continue;
}
if (field === "headers") {
@@ -1222,6 +4346,11 @@ function findMcpRedactionPaths(tree: jsonc.Node): BackupRedactionPath[] {
return paths;
}
+/**
+ * JSON, not delimiter-joined: server and header names come from the backup, so a crafted
+ * name containing the delimiter could collide with another entry's field path and shadow
+ * its resolution (e.g. skipping the header drop for a server named `safe\u0000url`).
+ */
function redactionPathKey(jsonPath: ReadonlyArray): string {
return JSON.stringify(jsonPath);
}
@@ -1242,15 +4371,6 @@ function validateMcpRedactionPaths(tree: jsonc.Node, paths: readonly BackupRedac
}
}
-/**
- * Documentation is the only thing a recursive collection publishes without asking. `skills/`
- * and `memory/global/` hold whatever the user put there, and no content scanner can decide
- * whether an arbitrary file is a credential: `{"password":"hunter2"}` has no distinguishing
- * shape. So the gate is structural rather than pattern-based, and anything outside the
- * documented set is surfaced for review instead of being published or silently dropped.
- */
-const AUTO_PUBLISHED_RECURSIVE_FILE = /\.(?:md|mdx|markdown|txt)$/i;
-
/** A name promising credentials earns review even when the extension is documentation. */
const CREDENTIAL_PATH_HINT =
/(?:^|[^a-z])(?:credential|credentials|secret|secrets|password|passwords|token|tokens|(?:api|private)(?:[^a-z/]+)?keys?|netrc|keychain|htpasswd)(?:[^a-z]|$)/i;
@@ -1334,7 +4454,7 @@ export function scanBackupFilesForSecrets(files: readonly BackupFile[]): string[
return files
.filter((file) => {
const content = file.content.toString("utf-8");
- if (SECRET_PATTERNS.some((pattern) => pattern.test(content))) return true;
+ if (matchesReviewableSecret(content)) return true;
if (file.path === "mcp.jsonc" && mcpConfigRequiresPublishApproval(content)) return true;
// Every collected file, not just the recursive ones: `agents/` is collected by name and
// its `.md` filter would otherwise auto-publish `agents/api-key.md`.
@@ -1372,7 +4492,7 @@ export async function createBackupPayload(
const mcpRedactionPaths: BackupRedactionPath[] = [];
const mcpFile = files.find((file) => file.path === "mcp.jsonc");
if (mcpFile && options.keepLocalSecrets !== true) {
- const redacted = redactMcpConfig(mcpFile.content);
+ const redacted = redactMcpConfig(mcpFile.content, options.muxRoot);
mcpFile.content = redacted.content;
mcpRedactionPaths.push(...redacted.redactionPaths);
}
@@ -1380,6 +4500,8 @@ export async function createBackupPayload(
path: "preferences.json",
content: serializeBackupPreferences(options.preferences),
});
+ const assembledBudget = createByteBudget();
+ takeBackupFileBytes(assembledBudget, files);
// Count and complexity only: this payload may be a local snapshot, whose names keep
// current-filesystem forms that portable validation would refuse. Collection already
// validated each name under local rules; publication re-checks with portable rules.
@@ -1387,6 +4509,73 @@ export async function createBackupPayload(
assertBackupPathComplexity(files.map((file) => file.path));
files.sort((a, b) => a.path.localeCompare(b.path));
+ // Backstop behind the redaction above, not the primary mechanism: a credential-format
+ // match in the finished payload always aborts, with no reportSecrets override. The local
+ // safety snapshot keeps secrets by design and never leaves the machine, so it is exempt.
+ if (options.keepLocalSecrets !== true) {
+ const leakedFiles = files
+ .filter((file) => {
+ // The path publishes alongside the content, and recursive collections take
+ // whatever a directory entry happens to be named.
+ const content = file.content.toString("utf-8");
+ // NUL-stripping reassembles ASCII tokens out of UTF-16 text, which decodes to
+ // interleaved NUL characters here; text published as prose has no business
+ // holding NULs, so this manufactures no match from ordinary content. NUL-free
+ // content strips to itself, so the second scan pass runs only when NULs exist
+ // rather than doubling the synchronous scan of a size-capped payload.
+ const targets = [content, file.path];
+ if (content.includes("\u0000")) targets.push(content.replaceAll("\u0000", ""));
+ // A reader's ordinary URL parsing decodes percent triplets in published
+ // documentation. Scan that one-pass decoded view too, but only when a percent
+ // sign exists so ordinary near-limit payloads pay no extra full-file pass.
+ if (AUTO_PUBLISHED_RECURSIVE_FILE.test(file.path) && content.includes("%")) {
+ const decoded = percentDecodeOnce(content);
+ targets.push(decoded);
+ if (decoded.includes("\u0000")) targets.push(decoded.replaceAll("\u0000", ""));
+ }
+ // Shell-normalized variants catch a token split by quoting or an expansion
+ // (`--token ghp_123\456...`, `ghp_...$9...`): the shell removes both on
+ // execution, and the published text reconstructs the same credential.
+ // Normalization works on parsed command strings, not the raw JSON text, whose
+ // escape encoding garbles the reassembly. Only command values are shell input;
+ // other strings (tool names, urls, prose) can legitimately hold quote-separated
+ // token-like fragments, and this block has no override.
+ if (file.path === "mcp.jsonc") {
+ const parsedMcp: unknown = jsonc.parse(content);
+ for (const text of collectCommandStrings(parsedMcp)) {
+ // Word-by-word, with real quoting rules: raw character stripping would
+ // join fragments the shell keeps apart (a backslash inside single quotes
+ // survives execution) and manufacture a match from a harmless command.
+ const words = executedShellWords(text);
+ targets.push(words.map((word) => unquoteShellWord(word)).join(" "));
+ // A simple parameter expansion that is unset at runtime vanishes,
+ // splicing the fragments around it into one token. Redaction localizes
+ // active expansions before publication; this pass is the backstop's own
+ // model of the same splice, independent of that layer.
+ targets.push(words.map((word) => unquoteShellWord(word, true)).join(" "));
+ // Pathname expansion can hand the process a token a deterministic glob
+ // spelling hides, and the published text collapses the same way for any
+ // reader.
+ targets.push(words.map((word) => unquoteShellWord(word, true, true)).join(" "));
+ }
+ // A standard URL parse hands any reader the decoded value, so a published
+ // url is scanned as what it decodes to, not just its encoded spelling. The
+ // WHATWG parser deletes embedded tab and newline separators before anything
+ // else, so they are removed first: `ghp_aaa\tbbb` reaches the client as the
+ // contiguous token. Separator removal cannot hide a match, because no token
+ // charset contains them.
+ for (const url of collectUrlStrings(parsedMcp)) {
+ const canonical = url.replaceAll("\t", "").replaceAll("\n", "").replaceAll("\r", "");
+ targets.push(percentDecodeOnce(canonical));
+ }
+ }
+ return targets.some(matchesCredentialToken);
+ })
+ .map((file) => file.path)
+ .sort();
+ if (leakedFiles.length > 0) throw new BackupCredentialDetectedError(leakedFiles);
+ }
+
if (options.reportSecrets !== true) {
const secretFiles = scanBackupFilesForSecrets(files);
if (secretFiles.length > 0) {
@@ -1469,7 +4658,7 @@ function assertPayloadWithinLimits(files: readonly BackupFile[], manifestJson: s
// cannot be one that every later read rejects.
const budget = createByteBudget();
budget(BACKUP_MANIFEST_FILE, Buffer.byteLength(manifestJson, "utf-8"));
- for (const file of files) budget(file.path, file.content.length);
+ takeBackupFileBytes(budget, files);
}
export async function writeBackupPayload(
@@ -1777,7 +4966,7 @@ function collectRedactionRestoreEdits(
// Only the paths handled by command or header resolution are skipped, so a mixed entry
// can still rehydrate its other redacted values. A dropped entry is skipped wholesale,
// since a nested edit would resurrect what it removed.
- if (resolvedServers.has(currentPath.join("\u0000"))) return;
+ if (resolvedServers.has(redactionPathKey(currentPath))) return;
if (typeof backup === "string" && isRedactedBackupValue(backup, currentPath, redactedPaths)) {
if (local !== undefined) edits.push({ path: currentPath, value: local });
return;
@@ -1928,6 +5117,18 @@ export async function collectMcpCommandApprovals(
if (!file) return [];
const restored = await resolveRestoredContent(muxRoot, file, mcpRedactions);
+ return collectApprovalsForResolvedMcp(muxRoot, restored);
+}
+
+/**
+ * Approvals for already-resolved MCP bytes, so restore can gate the exact content its
+ * plan writes: the local file can change between reads, and a separate resolution could
+ * observe a different rehydration than the one being written.
+ */
+export async function collectApprovalsForResolvedMcp(
+ muxRoot: string,
+ restored: Buffer
+): Promise {
const incoming = readServerCommands(restored.toString("utf-8"));
const localText = await readLocalMcpText(muxRoot);
const local =
@@ -1998,6 +5199,9 @@ async function restoreMcpFile(
? preserveLocalOnlyMcpServers(backupTree, localTree, localText)
: ({ kind: "none" } satisfies LocalMcpServerMerge);
const resolved = resolveRestoredCommands(backup, local, edits, redactedPaths);
+ for (const path of resolveRestoredUrls(backup, local, edits, resolved, redactedPaths)) {
+ resolved.add(path);
+ }
for (const path of resolveRestoredHeaders(
backup,
local,
@@ -2187,12 +5391,62 @@ function resolveRestoredCommands(
const hasUrl = url !== undefined && url !== "" && !containsRedaction(url);
const removed: jsonc.JSONPath = hasUrl ? ["servers", name, "command"] : ["servers", name];
edits.push({ path: removed, value: undefined });
- handled.add(removed.join("\u0000"));
+ handled.add(redactionPathKey(removed));
continue;
}
const commandPath = isBareMarker ? barePath : objectPath;
edits.push({ path: commandPath, value: localCommand });
- handled.add(commandPath.join("\u0000"));
+ handled.add(redactionPathKey(commandPath));
+ }
+ return handled;
+}
+
+/**
+ * Mirrors the command resolution for `url`: a marker is only ever replaced by the local
+ * value at the same path. Without one the marker must not survive as the endpoint the
+ * entry connects to, so the url is dropped when the entry still has a usable command
+ * (`collectMcpCommandApprovals` gates any command that removal makes runnable) and the
+ * whole server is removed otherwise.
+ */
+function resolveRestoredUrls(
+ backup: Record,
+ local: Record,
+ edits: Array<{ path: jsonc.JSONPath; value: unknown }>,
+ resolvedServers: ReadonlySet,
+ redactedPaths: ReadonlySet | undefined
+): Set {
+ const handled = new Set();
+ const servers = readRecord(backup.servers);
+ if (!servers) return handled;
+ const localServers = readRecord(local.servers) ?? {};
+
+ for (const [name, entry] of Object.entries(servers)) {
+ // An entry the command resolution removed has no url left to decide about.
+ if (resolvedServers.has(redactionPathKey(["servers", name]))) continue;
+ const record = readRecord(entry);
+ const url = record?.url;
+ const urlPath: jsonc.JSONPath = ["servers", name, "url"];
+ if (typeof url !== "string" || !isRedactedBackupValue(url, urlPath, redactedPaths)) continue;
+
+ const localUrl = readUrl(readRecord(readOwn(localServers, name)));
+ if (localUrl !== undefined) {
+ edits.push({ path: urlPath, value: localUrl });
+ handled.add(redactionPathKey(urlPath));
+ continue;
+ }
+ const commandPath: jsonc.JSONPath = ["servers", name, "command"];
+ const command = record?.command;
+ // Either the command resolution already put the local command back at this path, or the
+ // backup carries a plain command of its own. A marker command never reaches the second
+ // arm: without a local command the command resolution removed the server above.
+ const hasCommand =
+ resolvedServers.has(redactionPathKey(commandPath)) ||
+ (typeof command === "string" &&
+ command.trim() !== "" &&
+ !isRedactedBackupValue(command, commandPath, redactedPaths));
+ const removed: jsonc.JSONPath = hasCommand ? urlPath : ["servers", name];
+ edits.push({ path: removed, value: undefined });
+ handled.add(redactionPathKey(removed));
}
return handled;
}
@@ -2232,14 +5486,14 @@ function resolveRestoredHeaders(
for (const [name, entry] of Object.entries(servers)) {
// An entry command resolution already removed has no headers left to decide about, and
// `jsonc.modify` cannot address a path whose parent this edit list deletes.
- if (resolvedServers.has(["servers", name].join("\u0000"))) continue;
+ if (resolvedServers.has(redactionPathKey(["servers", name]))) continue;
const rawHeaders = readRecord(entry)?.headers;
if (rawHeaders === undefined) continue;
const localServer = readRecord(readOwn(localServers, name));
const headersPath: jsonc.JSONPath = ["servers", name, "headers"];
// The whole subtree is withheld from the generic walk, so no header can be rehydrated
// by a path this function did not decide on.
- handled.add(headersPath.join("\u0000"));
+ handled.add(redactionPathKey(headersPath));
const headers = readRecord(rawHeaders);
const endpointMatches =
@@ -2323,6 +5577,73 @@ function readUrl(server: Record | undefined): string | undefine
return typeof url === "string" ? url : undefined;
}
+/** Every command string a shell would execute, for shell-normalized credential scans. */
+function collectCommandStrings(root: unknown): string[] {
+ const servers = readRecord(readRecord(root)?.servers);
+ if (!servers) return [];
+ const commands: string[] = [];
+ for (const value of Object.values(servers)) {
+ const command = typeof value === "string" ? value : readRecord(value)?.command;
+ if (typeof command === "string") commands.push(command);
+ }
+ return commands;
+}
+
+function collectUrlStrings(root: unknown): string[] {
+ const servers = readRecord(readRecord(root)?.servers);
+ if (!servers) return [];
+ const urls: string[] = [];
+ for (const value of Object.values(servers)) {
+ const url = readRecord(value)?.url;
+ if (typeof url === "string") urls.push(url);
+ }
+ return urls;
+}
+
+/**
+ * One decoding pass, never a loop: a double-encoded `%2561` reaches a client as the
+ * literal `%61` a single standard parse yields, and repeated decoding would manufacture
+ * blocks from spellings no consumer resolves to the credential.
+ */
+function hexDigitValue(code: number): number {
+ if (code >= 48 && code <= 57) return code - 48;
+ if (code >= 65 && code <= 70) return code - 55;
+ if (code >= 97 && code <= 102) return code - 87;
+ return -1;
+}
+
+/** One-pass %XX decoding without one regex callback/allocation per triplet. */
+function percentDecodeOnce(text: string): string {
+ if (!text.includes("%")) return text;
+ const chunks: string[] = [];
+ const codes = new Uint16Array(16_384);
+ let used = 0;
+ function flush(): void {
+ if (used === 0) return;
+ chunks.push(String.fromCharCode(...codes.subarray(0, used)));
+ used = 0;
+ }
+ for (let i = 0; i < text.length; i += 1) {
+ const code = text.charCodeAt(i);
+ if (code === 37 && i + 2 < text.length) {
+ const high = hexDigitValue(text.charCodeAt(i + 1));
+ const low = hexDigitValue(text.charCodeAt(i + 2));
+ if (high >= 0 && low >= 0) {
+ codes[used] = (high << 4) | low;
+ used += 1;
+ i += 2;
+ if (used === codes.length) flush();
+ continue;
+ }
+ }
+ codes[used] = code;
+ used += 1;
+ if (used === codes.length) flush();
+ }
+ flush();
+ return chunks.join("");
+}
+
/**
* Structural, never `isPlainObject`: `jsonc.parse` assigns a `__proto__` key through the
* prototype, so a polluted entry has a non-standard prototype but must stay visible here,
@@ -2488,18 +5809,19 @@ export async function restoreBackupPayload(
.filter((file) => file.path !== "preferences.json")
.map((file) => file.path)
);
+ const plan = await planRestoreWrites(options.muxRoot, options.payload);
// Recomputed here rather than trusted from the preview, so an approval cannot authorize
- // a command the repository changed between the preview and this restore.
+ // a command the repository changed between the preview and this restore. Computed from
+ // the exact bytes the plan writes, not a separate resolution: the local file can change
+ // between reads, and a divergent rehydration could exempt a command (url restored,
+ // shadowing it) that the planned content then carries runnable (url dropped).
+ const plannedMcp = plan.writes.find((write) => write.path === "mcp.jsonc");
assertBackupCommandsApproved(
- await collectMcpCommandApprovals(
- options.muxRoot,
- options.payload.files,
- options.payload.manifest.mcpRedactions
- ),
+ plannedMcp === undefined
+ ? []
+ : await collectApprovalsForResolvedMcp(options.muxRoot, plannedMcp.content),
options.approvedCommandTokens
);
-
- const plan = await planRestoreWrites(options.muxRoot, options.payload);
// Classify against the pre-restore filesystem state before writes change file identities.
const { localOnly } = await localOnlyPayloadFiles(options.muxRoot, localPaths, restoredPaths);
diff --git a/tests/ui/BackupSection.test.ts b/tests/ui/BackupSection.test.ts
index 1b321f8f54..8b52d9366a 100644
--- a/tests/ui/BackupSection.test.ts
+++ b/tests/ui/BackupSection.test.ts
@@ -369,6 +369,7 @@ describe("BackupSection", () => {
code: "SECRET_DETECTED",
message: "Potential secrets were found in the backup payload: AGENTS.md",
files: ["AGENTS.md"],
+ secretApproval: "digest-preview",
},
});
@@ -382,6 +383,84 @@ describe("BackupSection", () => {
await waitFor(() => expect(override.getAttribute("data-state")).toBe("checked"));
});
+ test("offers no override for a credential block that carries no approval digest", async () => {
+ const { client, view } = renderBackupSection();
+ const canvas = within(view.container);
+ await canvas.findByText("Settings backup");
+
+ jest.spyOn(client.backup, "push").mockResolvedValueOnce({
+ success: false,
+ error: {
+ code: "SECRET_DETECTED",
+ message:
+ "Backup blocked: values matching known credential formats were found in mcp.jsonc.",
+ files: ["mcp.jsonc"],
+ },
+ });
+ fireEvent.click(canvas.getByRole("button", { name: "Back up now" }));
+
+ await canvas.findByText(/Backup blocked/i);
+ expect(canvas.queryByRole("checkbox", { name: "Override secret scan" })).toBeNull();
+ });
+
+ test("clears a stale override when a preview hits the credential block", async () => {
+ const { client, view } = renderBackupSection();
+ const canvas = within(view.container);
+ await canvas.findByText("Settings backup");
+
+ jest.spyOn(client.backup, "push").mockResolvedValueOnce({
+ success: false,
+ error: {
+ code: "SECRET_DETECTED",
+ message: "Potential secrets were found in the backup payload: AGENTS.md",
+ files: ["AGENTS.md"],
+ secretApproval: "digest-stale",
+ },
+ });
+ fireEvent.click(canvas.getByRole("button", { name: "Back up now" }));
+ await canvas.findByText(/Potential secrets were found/i);
+ expect(canvas.getByRole("checkbox", { name: "Override secret scan" })).toBeTruthy();
+
+ jest.spyOn(client.backup, "preview").mockResolvedValueOnce({
+ success: false,
+ error: {
+ code: "SECRET_DETECTED",
+ message:
+ "Backup blocked: values matching known credential formats were found in mcp.jsonc.",
+ files: ["mcp.jsonc"],
+ },
+ });
+ fireEvent.click(canvas.getByRole("button", { name: "Preview changes" }));
+ await canvas.findByText(/Backup blocked/i);
+ expect(canvas.queryByRole("checkbox", { name: "Override secret scan" })).toBeNull();
+ });
+
+ test("clears a stale override when a preview rejects outright", async () => {
+ const { client, view } = renderBackupSection();
+ const canvas = within(view.container);
+ await canvas.findByText("Settings backup");
+
+ jest.spyOn(client.backup, "push").mockResolvedValueOnce({
+ success: false,
+ error: {
+ code: "SECRET_DETECTED",
+ message: "Potential secrets were found in the backup payload: AGENTS.md",
+ files: ["AGENTS.md"],
+ secretApproval: "digest-stale",
+ },
+ });
+ fireEvent.click(canvas.getByRole("button", { name: "Back up now" }));
+ await canvas.findByText(/Potential secrets were found/i);
+ expect(canvas.getByRole("checkbox", { name: "Override secret scan" })).toBeTruthy();
+
+ // A rejection is a transport failure, not a scan result: the previous scan's
+ // override must not keep rendering beside the unrelated error.
+ jest.spyOn(client.backup, "preview").mockRejectedValueOnce(new Error("ipc closed"));
+ fireEvent.click(canvas.getByRole("button", { name: "Preview changes" }));
+ await canvas.findByText(/ipc closed/i);
+ expect(canvas.queryByRole("checkbox", { name: "Override secret scan" })).toBeNull();
+ });
+
test("sends the approved digest and resets when the blocked payload changes", async () => {
const { client, view } = renderBackupSection();
const canvas = within(view.container);
@@ -440,7 +519,12 @@ describe("BackupSection", () => {
const push = jest.spyOn(client.backup, "push").mockResolvedValueOnce({
success: false,
- error: { code: "SECRET_DETECTED", message: "Potential secrets", files: ["AGENTS.md"] },
+ error: {
+ code: "SECRET_DETECTED",
+ message: "Potential secrets",
+ files: ["AGENTS.md"],
+ secretApproval: "digest-agents",
+ },
});
fireEvent.click(canvas.getByRole("button", { name: "Back up now" }));