From cbfabe752e1f17748f8c83863137d5f8e84d1cbb Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Wed, 26 Aug 2026 19:32:20 +0000
Subject: [PATCH 001/116] fix: redact MCP command env values and credential
URLs in settings backup export
The export projection treated 'command' and 'url' as fully portable strings,
so env-style credentials embedded in a command (FOO_TOKEN=... cmd) and
credentials inside URLs were published verbatim with an empty mcpRedactions.
Redact assignment values in command strings (object and bare-string servers),
redact credential-bearing URLs whole-value, and record both in the manifest so
restore rehydrates them from the local config. Manifest sha256 stays computed
over the redacted bytes as written.
Defense in depth: createBackupPayload now hard-fails when a known credential
token format survives in the finished payload, with no approval override; the
local safety snapshot stays exempt. The Settings UI hides the secret-scan
override for blocks that carry no approval digest.
---
.../Settings/Sections/BackupSection.tsx | 12 +-
.../backup/backupService.integration.test.ts | 33 ++--
src/node/services/backup/payload.test.ts | 168 ++++++++++++++++--
src/node/services/backup/payload.ts | 91 +++++++++-
tests/ui/BackupSection.test.ts | 28 ++-
5 files changed, 285 insertions(+), 47 deletions(-)
diff --git a/src/browser/features/Settings/Sections/BackupSection.tsx b/src/browser/features/Settings/Sections/BackupSection.tsx
index 2f6a40dcc5a..1e7504ee7be 100644
--- a/src/browser/features/Settings/Sections/BackupSection.tsx
+++ b/src/browser/features/Settings/Sections/BackupSection.tsx
@@ -600,10 +600,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 +713,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 ? (
{
);
});
- 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 b8609aee8e1..3b481d76bfb 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -9,6 +9,7 @@ import { execFileAsync } from "@/node/utils/disposableExec";
import {
BACKUP_SCHEMA_VERSION,
BackupCommandApprovalRequiredError,
+ BackupCredentialDetectedError,
assertBackupCommandsApproved,
MAX_BACKUP_DIRECTORY_COUNT,
MAX_BACKUP_FILE_BYTES,
@@ -215,7 +216,7 @@ describe("backup payload", () => {
});
});
- it("keeps MCP commands and URLs while redacting literal header values", async () => {
+ it("redacts credential-bearing URLs and literal header values while keeping plain commands", async () => {
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
@@ -256,18 +257,156 @@ describe("backup payload", () => {
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.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.headers.Authorization"]);
+ 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);
+ });
+
+ 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("blocks the export outright when a credential pattern survives redaction", async () => {
+ const token = "glsa_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_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("redacts identically when the settings root is a legacy .mux directory", async () => {
+ const legacyRoot = path.join(tempDir, ".mux");
+ await fs.mkdir(legacyRoot);
+ await writeFixtureFile(
+ legacyRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: "TOKEN=hunter2 mcp-grafana" } } })
+ );
+
+ const payload = await createBackupPayload({
+ muxRoot: legacyRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: ".mux",
+ 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-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 () => {
@@ -2444,7 +2583,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",
@@ -2473,27 +2612,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"]);
}
});
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 076ebc56c96..6d17a448fdd 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -67,13 +67,27 @@ function isForbiddenBasename(name: string): boolean {
function isHiddenName(name: string): boolean {
return name.startsWith(".");
}
-const SECRET_PATTERNS = [
+/**
+ * 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 CREDENTIAL_TOKEN_PATTERNS = [
/\bsk-[A-Za-z0-9_-]{16,}\b/,
/\bghp_[A-Za-z0-9]{20,}\b/,
+ /\bgho_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
+ /\bglsa_[A-Za-z0-9_]{20,}\b/,
+ /\blin_api_[A-Za-z0-9]{16,}\b/,
+ /\bntn_[A-Za-z0-9]{16,}\b/,
/\bAKIA[0-9A-Z]{16}\b/,
+ /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
+] as const;
+
+const SECRET_PATTERNS = [
+ ...CREDENTIAL_TOKEN_PATTERNS,
/\bAIza[A-Za-z0-9_-]{35,}/,
- /\bxoxb-[A-Za-z0-9-]{10,}\b/,
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
] as const;
@@ -127,6 +141,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";
@@ -1055,6 +1084,10 @@ function isUnsupportedServerMap(value: unknown): boolean {
* 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",
@@ -1098,6 +1131,22 @@ function valueHasRedactionAtPath(
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. Restore puts the whole local command back at that path.
+ */
+const COMMAND_ENV_ASSIGNMENT = /(^|\s)([A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g;
+
+function redactCommandEnvAssignments(command: string): string {
+ return command.replace(
+ COMMAND_ENV_ASSIGNMENT,
+ (_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
+ );
+}
+
function redactMcpConfig(content: Buffer): {
content: Buffer;
redactionPaths: BackupRedactionPath[];
@@ -1112,6 +1161,13 @@ function redactMcpConfig(content: Buffer): {
redactionPaths.push([...jsonPath]);
}
+ function redactCommand(jsonPath: jsonc.JSONPath, command: string): void {
+ const redacted = redactCommandEnvAssignments(command);
+ if (redacted === command) return;
+ edits.push({ path: jsonPath, value: redacted });
+ redactionPaths.push([...jsonPath]);
+ }
+
function finish(): { content: Buffer; redactionPaths: BackupRedactionPath[] } {
const projected = serializeProjectedMcp(applyJsoncEdits(text, edits));
const retainedRedactionPaths = redactionPaths.filter((jsonPath) =>
@@ -1148,7 +1204,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 +1223,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") {
@@ -1387,6 +1456,20 @@ 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) => {
+ const content = file.content.toString("utf-8");
+ return CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(content));
+ })
+ .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) {
diff --git a/tests/ui/BackupSection.test.ts b/tests/ui/BackupSection.test.ts
index 1b321f8f547..67cb56d9269 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,26 @@ 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("sends the approved digest and resets when the blocked payload changes", async () => {
const { client, view } = renderBackupSection();
const canvas = within(view.container);
@@ -440,7 +461,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" }));
From 7c2d5ad4be63ac943bfbaf9ec1d47f3c414b2be9 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Wed, 26 Aug 2026 23:54:34 +0000
Subject: [PATCH 002/116] fix: consume whole shell words in command redaction
and resolve redacted URLs on restore
---
src/node/services/backup/payload.test.ts | 89 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 84 +++++++++++++++++++++-
2 files changed, 170 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 3b481d76bfb..ed00fcd31fe 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -314,6 +314,40 @@ describe("backup payload", () => {
expect((await readBackupPayload(destination)).redactions).toEqual(payload.redactions);
});
+ 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 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("restores an inline-redacted command from the local config and drops it elsewhere", async () => {
const command = "FOO_TOKEN=hunter2 notes-mcp --verbose";
await writeFixtureFile(
@@ -354,6 +388,61 @@ describe("backup payload", () => {
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_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_00000000";
await writeFixtureFile(
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 6d17a448fdd..2ba8a3aa9c7 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1136,15 +1136,40 @@ function valueHasRedactionAtPath(
* (`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. Restore puts the whole local command back at that path.
+ * 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.
*/
-const COMMAND_ENV_ASSIGNMENT = /(^|\s)([A-Za-z_][A-Za-z0-9_]*=)("[^"]*"|'[^']*'|\S+)/g;
+const COMMAND_ENV_ASSIGNMENT =
+ /(^|\s)([A-Za-z_][A-Za-z0-9_]*=)((?:\\[\s\S]|'[^']*'|"(?:\\[\s\S]|[^"\\])*"|[^\s\\'"]+)+)/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(
+ `(^|\\s)[A-Za-z_][A-Za-z0-9_]*=(?!${REDACTED_BACKUP_VALUE}(?=\\s|$))(?=\\S)`
+);
+
+/** `$(`, `\``, and `${` splice one word across whitespace the grammar cannot see past. */
+const SHELL_EXPANSION = /\$\(|\$\{|`/;
function redactCommandEnvAssignments(command: string): string {
- return command.replace(
+ const redacted = command.replace(
COMMAND_ENV_ASSIGNMENT,
(_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
);
+ // 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. Checked even
+ // when nothing was replaced: an unconsumable value means the replacement never ran.
+ if (
+ UNCONSUMED_ASSIGNMENT.test(redacted) ||
+ (redacted !== command && SHELL_EXPANSION.test(command))
+ ) {
+ return REDACTED_BACKUP_VALUE;
+ }
+ return redacted;
}
function redactMcpConfig(content: Buffer): {
@@ -2081,6 +2106,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,
@@ -2280,6 +2308,56 @@ function resolveRestoredCommands(
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(["servers", name].join("\u0000"))) 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(urlPath.join("\u0000"));
+ 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(commandPath.join("\u0000")) ||
+ (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(removed.join("\u0000"));
+ }
+ return handled;
+}
+
/**
* A restored header value is only ever the local value at that exact path, or nothing.
*
From 543d699c8f4514af8033b5a894a612f241e803b9 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:11:13 +0000
Subject: [PATCH 003/116] fix: recognize env assignments after shell operators
and fail closed on quote-led assignments
---
src/node/services/backup/payload.test.ts | 60 +++++++++++++++++-------
src/node/services/backup/payload.ts | 28 +++++++++--
2 files changed, 67 insertions(+), 21 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ed00fcd31fe..e4944331abd 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -314,6 +314,25 @@ describe("backup payload", () => {
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.
@@ -329,22 +348,31 @@ describe("backup payload", () => {
["TOKEN=${X:-abc hunter2} notes-mcp", REDACTED_BACKUP_VALUE],
];
for (const [command, expected] of cases) {
- 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"]]);
+ 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`],
+ ["mcp-a|TOKEN=hunter2 mcp-b", `mcp-a|TOKEN=${REDACTED_BACKUP_VALUE} mcp-b`],
+ ["(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],
+ ];
+ for (const [command, expected] of cases) {
+ await expectCommandRedaction(command, expected);
}
});
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 2ba8a3aa9c7..64db5d682a3 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1140,8 +1140,15 @@ function valueHasRedactionAtPath(
* 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.
*/
-const COMMAND_ENV_ASSIGNMENT =
- /(^|\s)([A-Za-z_][A-Za-z0-9_]*=)((?:\\[\s\S]|'[^']*'|"(?:\\[\s\S]|[^"\\])*"|[^\s\\'"]+)+)/g;
+// 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.
+const SHELL_WORD_BREAK = ";&|<>(){}`";
+const ASSIGNMENT_NAME = "[A-Za-z_][A-Za-z0-9_]*=";
+const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}]+)+`;
+const COMMAND_ENV_ASSIGNMENT = new RegExp(
+ `(^|[\\s${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
+ "g"
+);
/**
* An assignment value the word grammar could not fully consume: after replacement its
@@ -1149,9 +1156,18 @@ const COMMAND_ENV_ASSIGNMENT =
* `=`. Either way the value's true extent is unknowable, e.g. an unterminated quote.
*/
const UNCONSUMED_ASSIGNMENT = new RegExp(
- `(^|\\s)[A-Za-z_][A-Za-z0-9_]*=(?!${REDACTED_BACKUP_VALUE}(?=\\s|$))(?=\\S)`
+ `(^|[\\s${SHELL_WORD_BREAK}])${ASSIGNMENT_NAME}` +
+ `(?!${REDACTED_BACKUP_VALUE}(?=[\\s${SHELL_WORD_BREAK}]|$))(?=[^\\s${SHELL_WORD_BREAK}])`
);
+/**
+ * To the shell a quote-led `NAME=` is word content, not an assignment, but `eval`- and
+ * `docker -e`-style consumers still read it as one, and where its value ends inside a
+ * quoted context is not decidable here. Tested against the replaced text so a consumed
+ * quoted value (`A="B=1"`) cannot fire it.
+ */
+const QUOTED_ASSIGNMENT = new RegExp(`['"]${ASSIGNMENT_NAME}`);
+
/** `$(`, `\``, and `${` splice one word across whitespace the grammar cannot see past. */
const SHELL_EXPANSION = /\$\(|\$\{|`/;
@@ -1161,10 +1177,12 @@ function redactCommandEnvAssignments(command: string): string {
(_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
);
// 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. Checked even
- // when nothing was replaced: an unconsumable value means the replacement never ran.
+ // 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.
if (
UNCONSUMED_ASSIGNMENT.test(redacted) ||
+ QUOTED_ASSIGNMENT.test(redacted) ||
(redacted !== command && SHELL_EXPANSION.test(command))
) {
return REDACTED_BACKUP_VALUE;
From 875b0c8c826b6057d932f33c7e4d05786c587150 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:28:37 +0000
Subject: [PATCH 004/116] fix: collision-safe restore path keys,
disguised-assignment detection, process substitution fail-closed
---
src/node/services/backup/payload.test.ts | 50 ++++++++++++
src/node/services/backup/payload.ts | 96 +++++++++++++++++++-----
2 files changed, 129 insertions(+), 17 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index e4944331abd..265fab4c7e0 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -370,6 +370,13 @@ describe("backup payload", () => {
// 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],
];
for (const [command, expected] of cases) {
await expectCommandRedaction(command, expected);
@@ -1516,6 +1523,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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 64db5d682a3..83454537644 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1160,16 +1160,73 @@ const UNCONSUMED_ASSIGNMENT = new RegExp(
`(?!${REDACTED_BACKUP_VALUE}(?=[\\s${SHELL_WORD_BREAK}]|$))(?=[^\\s${SHELL_WORD_BREAK}])`
);
+/** One whole shell word, however its quoted and escaped segments interleave. */
+const SHELL_WORD = new RegExp(
+ `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}])+`,
+ "g"
+);
+const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
+
+/**
+ * Quote removal only, and simplified: every backslash escapes, including inside double
+ * quotes where the shell keeps some. The difference only ever turns more words into
+ * detected assignments, never fewer.
+ */
+function unquoteShellWord(word: string): string {
+ let result = "";
+ let i = 0;
+ while (i < word.length) {
+ const char = word[i];
+ if (char === "\\") {
+ 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] !== '"') {
+ if (word[j] === "\\") {
+ result += word[j + 1] ?? "";
+ j += 2;
+ } else {
+ result += word[j];
+ j += 1;
+ }
+ }
+ i = j + 1;
+ continue;
+ }
+ result += char;
+ i += 1;
+ }
+ return result;
+}
+
/**
- * To the shell a quote-led `NAME=` is word content, not an assignment, but `eval`- and
- * `docker -e`-style consumers still read it as one, and where its value ends inside a
- * quoted context is not decidable here. Tested against the replaced text so a consumed
- * quoted value (`A="B=1"`) cannot fire it.
+ * A word quote removal turns into a `NAME=value` assignment (`TOKEN\\=x`, `'TOKEN'=x`,
+ * `"TOKEN=a b"`): the shell does not treat it as one, but `env`- and `eval`-style
+ * consumers do, and where its value ends inside quoting is not decidable here. A word
+ * already holding a marker was consumed by the replacement above (`A="B=1"` cannot fire).
*/
-const QUOTED_ASSIGNMENT = new RegExp(`['"]${ASSIGNMENT_NAME}`);
+function hasDisguisedAssignment(redacted: string): boolean {
+ for (const word of redacted.match(SHELL_WORD) ?? []) {
+ if (word.includes(REDACTED_BACKUP_VALUE) || !word.includes("=")) continue;
+ if (ASSIGNMENT_START.test(unquoteShellWord(word))) return true;
+ }
+ return false;
+}
-/** `$(`, `\``, and `${` splice one word across whitespace the grammar cannot see past. */
-const SHELL_EXPANSION = /\$\(|\$\{|`/;
+/**
+ * `$(`, `\``, `${`, `<(`, and `>(` splice one word across whitespace the grammar cannot
+ * see past.
+ */
+const SHELL_EXPANSION = /\$\(|\$\{|`|<\(|>\(/;
function redactCommandEnvAssignments(command: string): string {
const redacted = command.replace(
@@ -1182,7 +1239,7 @@ function redactCommandEnvAssignments(command: string): string {
// value means the replacement never saw it.
if (
UNCONSUMED_ASSIGNMENT.test(redacted) ||
- QUOTED_ASSIGNMENT.test(redacted) ||
+ hasDisguisedAssignment(redacted) ||
(redacted !== command && SHELL_EXPANSION.test(command))
) {
return REDACTED_BACKUP_VALUE;
@@ -1334,6 +1391,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);
}
@@ -1903,7 +1965,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;
@@ -2316,12 +2378,12 @@ 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;
}
@@ -2347,7 +2409,7 @@ function resolveRestoredUrls(
for (const [name, entry] of Object.entries(servers)) {
// An entry the command resolution removed has no url left to decide about.
- if (resolvedServers.has(["servers", name].join("\u0000"))) continue;
+ if (resolvedServers.has(redactionPathKey(["servers", name]))) continue;
const record = readRecord(entry);
const url = record?.url;
const urlPath: jsonc.JSONPath = ["servers", name, "url"];
@@ -2356,7 +2418,7 @@ function resolveRestoredUrls(
const localUrl = readUrl(readRecord(readOwn(localServers, name)));
if (localUrl !== undefined) {
edits.push({ path: urlPath, value: localUrl });
- handled.add(urlPath.join("\u0000"));
+ handled.add(redactionPathKey(urlPath));
continue;
}
const commandPath: jsonc.JSONPath = ["servers", name, "command"];
@@ -2365,13 +2427,13 @@ function resolveRestoredUrls(
// 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(commandPath.join("\u0000")) ||
+ 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(removed.join("\u0000"));
+ handled.add(redactionPathKey(removed));
}
return handled;
}
@@ -2411,14 +2473,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 =
From b9f9f51be0f328b300729df46d91f9f424fa2a46 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:42:57 +0000
Subject: [PATCH 005/116] fix: recognize bash append assignments in command
redaction
---
src/node/services/backup/payload.test.ts | 6 ++++++
src/node/services/backup/payload.ts | 3 ++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 265fab4c7e0..bdbf35f51ca 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -377,6 +377,12 @@ describe("backup payload", () => {
// 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],
+ // 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],
];
for (const [command, expected] of cases) {
await expectCommandRedaction(command, expected);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 83454537644..a65ae026bc1 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1143,7 +1143,8 @@ function valueHasRedactionAtPath(
// 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.
const SHELL_WORD_BREAK = ";&|<>(){}`";
-const ASSIGNMENT_NAME = "[A-Za-z_][A-Za-z0-9_]*=";
+// `+?=`: Bash `NAME+=value` appends, or plainly sets an unset name, and exports the same way.
+const ASSIGNMENT_NAME = "[A-Za-z_][A-Za-z0-9_]*\\+?=";
const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}]+)+`;
const COMMAND_ENV_ASSIGNMENT = new RegExp(
`(^|[\\s${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
From 9fd948f88e749bc3491e69812e0ff70e3f54ff91 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 00:55:26 +0000
Subject: [PATCH 006/116] fix: detect assignments disguised by ANSI-C and
locale quoting
---
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 5 +++++
2 files changed, 8 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index bdbf35f51ca..88bc89d6ae1 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -383,6 +383,9 @@ describe("backup payload", () => {
["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],
+ // 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],
];
for (const [command, expected] of cases) {
await expectCommandRedaction(command, expected);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a65ae026bc1..e954c1fb12f 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1178,6 +1178,11 @@ function unquoteShellWord(word: string): string {
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 === "\\") {
result += word[i + 1] ?? "";
i += 2;
From 9404c60c7b1e7bafd1b4c9803bb8e6f8a0df75a0 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:19:24 +0000
Subject: [PATCH 007/116] fix: fail closed on expansion carriers and env
split-string assignment spellings
---
src/node/services/backup/payload.test.ts | 7 ++++++
src/node/services/backup/payload.ts | 31 +++++++++++++++++-------
2 files changed, 29 insertions(+), 9 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 88bc89d6ae1..6e7214c167f 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -386,6 +386,13 @@ describe("backup payload", () => {
// 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],
+ // GNU env re-splits a split-string value into assignments.
+ ["env --split-string='TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
+ ["env -STOKEN=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\\x3dhunter2' mcp-server", REDACTED_BACKUP_VALUE],
+ ["mcp-run ${X:-hunter2}", REDACTED_BACKUP_VALUE],
];
for (const [command, expected] of cases) {
await expectCommandRedaction(command, expected);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e954c1fb12f..a876c478321 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1214,25 +1214,37 @@ function unquoteShellWord(word: string): string {
return result;
}
+/** GNU `env -S`/`--split-string` re-splits its attached value into assignments. */
+const SPLIT_STRING_OPTION = /^-[A-Za-z]*S|^--split-string/;
+
/**
- * A word quote removal turns into a `NAME=value` assignment (`TOKEN\\=x`, `'TOKEN'=x`,
- * `"TOKEN=a b"`): the shell does not treat it as one, but `env`- and `eval`-style
- * consumers do, and where its value ends inside quoting is not decidable here. A word
- * already holding a marker was consumed by the replacement above (`A="B=1"` cannot fire).
+ * Words that hand a downstream consumer an assignment the shell itself does not see,
+ * none of them decidable here: a quote-mangled `NAME=` spelling for `env`/`eval`
+ * (`TOKEN\\=x`, `'TOKEN'=x`, `"TOKEN=a b"`), a quoted region whose whitespace an
+ * `env -S`-style re-split would break into assignments, or a split-string option with
+ * its value attached. A word already holding a marker was consumed by the replacement
+ * above (`A="B=1"` cannot fire).
*/
function hasDisguisedAssignment(redacted: string): boolean {
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (word.includes(REDACTED_BACKUP_VALUE) || !word.includes("=")) continue;
- if (ASSIGNMENT_START.test(unquoteShellWord(word))) return true;
+ const unquoted = unquoteShellWord(word);
+ if (ASSIGNMENT_START.test(unquoted)) return true;
+ if (/\s/.test(unquoted)) return true;
+ if (SPLIT_STRING_OPTION.test(unquoted)) return true;
}
return false;
}
/**
- * `$(`, `\``, `${`, `<(`, and `>(` splice one word across whitespace the grammar cannot
- * see past.
+ * An expansion body can carry arbitrary bytes into one runtime word (`TOKEN$(printf
+ * =hunter2)`, `$'TOKEN\x3d...'`), so its mere presence makes assignment detection
+ * undecidable, whether or not the grammar matched an assignment elsewhere.
*/
-const SHELL_EXPANSION = /\$\(|\$\{|`|<\(|>\(/;
+const CARRIER_EXPANSION = /\$\(|\$\{|\$'|\$"|`/;
+
+/** Process substitution passes bytes by file, ambiguous once an assignment matched. */
+const PROCESS_SUBSTITUTION = /<\(|>\(/;
function redactCommandEnvAssignments(command: string): string {
const redacted = command.replace(
@@ -1246,7 +1258,8 @@ function redactCommandEnvAssignments(command: string): string {
if (
UNCONSUMED_ASSIGNMENT.test(redacted) ||
hasDisguisedAssignment(redacted) ||
- (redacted !== command && SHELL_EXPANSION.test(command))
+ CARRIER_EXPANSION.test(command) ||
+ (redacted !== command && PROCESS_SUBSTITUTION.test(command))
) {
return REDACTED_BACKUP_VALUE;
}
From 38cdefbd507ef9e99fd49dddb111ffe3826b81cb Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:24:10 +0000
Subject: [PATCH 008/116] fix: drop dead re-split whitespace rule, pin
quote-embedded assignment behavior
---
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 11 +++++------
2 files changed, 8 insertions(+), 6 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 6e7214c167f..2ad655e028c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -386,8 +386,11 @@ describe("backup payload", () => {
// 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],
+ // Boundaries match in the raw text, so a quote-embedded assignment still redacts.
+ ["sh -c 'exec TOKEN=hunter2 mcp'", `sh -c 'exec TOKEN=${REDACTED_BACKUP_VALUE} mcp'`],
// GNU env re-splits a split-string value into assignments.
["env --split-string='TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
+ ["env -S'TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
["env -STOKEN=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],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a876c478321..3816b533444 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1219,18 +1219,17 @@ const SPLIT_STRING_OPTION = /^-[A-Za-z]*S|^--split-string/;
/**
* Words that hand a downstream consumer an assignment the shell itself does not see,
- * none of them decidable here: a quote-mangled `NAME=` spelling for `env`/`eval`
- * (`TOKEN\\=x`, `'TOKEN'=x`, `"TOKEN=a b"`), a quoted region whose whitespace an
- * `env -S`-style re-split would break into assignments, or a split-string option with
- * its value attached. A word already holding a marker was consumed by the replacement
- * above (`A="B=1"` cannot fire).
+ * neither decidable here: a quote-mangled `NAME=` spelling for `env`/`eval`
+ * (`TOKEN\\=x`, `'TOKEN'=x`, `"TOKEN=a b"`), or a split-string option with its value
+ * attached. A word already holding a marker was consumed by the replacement above
+ * (`A="B=1"` cannot fire), which also covers assignments quoting embeds mid-word after
+ * a space, since the replacement matches boundaries in the raw text.
*/
function hasDisguisedAssignment(redacted: string): boolean {
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (word.includes(REDACTED_BACKUP_VALUE) || !word.includes("=")) continue;
const unquoted = unquoteShellWord(word);
if (ASSIGNMENT_START.test(unquoted)) return true;
- if (/\s/.test(unquoted)) return true;
if (SPLIT_STRING_OPTION.test(unquoted)) return true;
}
return false;
From 082a5302dffffe75de1c5bf863ab5cf714e6fba6 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:33:56 +0000
Subject: [PATCH 009/116] fix: fail closed on interpreter script words and
non-bash assignment spellings
---
src/node/services/backup/payload.test.ts | 9 +++++++--
src/node/services/backup/payload.ts | 24 ++++++++++++++++++------
2 files changed, 25 insertions(+), 8 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 2ad655e028c..3dad86aa57b 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -386,8 +386,13 @@ describe("backup payload", () => {
// 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],
- // Boundaries match in the raw text, so a quote-embedded assignment still redacts.
- ["sh -c 'exec TOKEN=hunter2 mcp'", `sh -c 'exec TOKEN=${REDACTED_BACKUP_VALUE} mcp'`],
+ // 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.
["env --split-string='TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
["env -S'TOKEN=hunter2 mcp-server'", REDACTED_BACKUP_VALUE],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3816b533444..a812b76cd45 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1217,20 +1217,32 @@ function unquoteShellWord(word: string): string {
/** GNU `env -S`/`--split-string` re-splits its attached value into assignments. */
const SPLIT_STRING_OPTION = /^-[A-Za-z]*S|^--split-string/;
+/** Exactly one replaced assignment, nothing else riding along in the same word. */
+const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
+
/**
* Words that hand a downstream consumer an assignment the shell itself does not see,
- * neither decidable here: a quote-mangled `NAME=` spelling for `env`/`eval`
- * (`TOKEN\\=x`, `'TOKEN'=x`, `"TOKEN=a b"`), or a split-string option with its value
- * attached. A word already holding a marker was consumed by the replacement above
- * (`A="B=1"` cannot fire), which also covers assignments quoting embeds mid-word after
- * a space, since the replacement matches boundaries in the raw text.
+ * 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): boolean {
for (const word of redacted.match(SHELL_WORD) ?? []) {
- if (word.includes(REDACTED_BACKUP_VALUE) || !word.includes("=")) continue;
+ if (CONSUMED_ASSIGNMENT.test(word)) continue;
const unquoted = unquoteShellWord(word);
+ // 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;
+ // A quote-mangled `NAME=` spelling (`TOKEN\\=x`, `'TOKEN'=x`) for `env`/`eval`.
if (ASSIGNMENT_START.test(unquoted)) return true;
+ // A split-string option with its value attached (`-STOKEN=x`).
if (SPLIT_STRING_OPTION.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;
}
From 39b3c721ebf6182499ef7b6c2eb117bddddebcc2 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:42:53 +0000
Subject: [PATCH 010/116] fix: treat legacy arithmetic as a carrier and scan
published paths in the credential backstop
---
src/node/services/backup/payload.test.ts | 26 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 13 ++++++++----
2 files changed, 35 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 3dad86aa57b..adba17897f9 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -399,6 +399,7 @@ describe("backup payload", () => {
["env -STOKEN=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],
];
@@ -534,6 +535,31 @@ describe("backup payload", () => {
expect(payloadFileText(snapshot, "mcp.jsonc")).toContain(token);
});
+ it("blocks the export when a credential format appears in a published path", async () => {
+ const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ 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("redacts identically when the settings root is a legacy .mux directory", async () => {
const legacyRoot = path.join(tempDir, ".mux");
await fs.mkdir(legacyRoot);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a812b76cd45..98842447a8e 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1249,10 +1249,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
/**
* An expansion body can carry arbitrary bytes into one runtime word (`TOKEN$(printf
- * =hunter2)`, `$'TOKEN\x3d...'`), so its mere presence makes assignment detection
- * undecidable, whether or not the grammar matched an assignment elsewhere.
+ * =hunter2)`, `$'TOKEN\x3d...'`, legacy arithmetic `TOKEN$[0]=...`), so its mere
+ * presence makes assignment detection undecidable, whether or not the grammar matched
+ * an assignment elsewhere.
*/
-const CARRIER_EXPANSION = /\$\(|\$\{|\$'|\$"|`/;
+const CARRIER_EXPANSION = /\$\(|\$\{|\$\[|\$'|\$"|`/;
/** Process substitution passes bytes by file, ambiguous once an assignment matched. */
const PROCESS_SUBSTITUTION = /<\(|>\(/;
@@ -1597,8 +1598,12 @@ export async function createBackupPayload(
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");
- return CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(content));
+ return CREDENTIAL_TOKEN_PATTERNS.some(
+ (pattern) => pattern.test(content) || pattern.test(file.path)
+ );
})
.map((file) => file.path)
.sort();
From ebb3f328af9217a6c4eb84635074a3b51434f41e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 01:59:45 +0000
Subject: [PATCH 011/116] fix: env operand names, brace-spliced assignments,
and quote-split tokens in the backstop
---
src/node/services/backup/payload.test.ts | 25 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 17 ++++++++++++----
2 files changed, 38 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index adba17897f9..35f1903625f 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -377,6 +377,11 @@ describe("backup payload", () => {
// 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`],
+ // Brace expansion splices assignment fragments across word breaks.
+ ["env {TOK,EN}=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["env TOK{A,B}=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`],
@@ -535,6 +540,26 @@ describe("backup payload", () => {
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_aaaaaaaaaaaaaaaaaa\\aaaaaaaaaaaaaaaaaa";
+ 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 the export when a credential format appears in a published path", async () => {
const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await writeFixtureFile(muxRoot, `skills/${token}/SKILL.md`, "docs only\n");
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 98842447a8e..6337bb849fb 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1143,8 +1143,11 @@ function valueHasRedactionAtPath(
// 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.
const SHELL_WORD_BREAK = ";&|<>(){}`";
-// `+?=`: Bash `NAME+=value` appends, or plainly sets an unset name, and exports the same way.
-const ASSIGNMENT_NAME = "[A-Za-z_][A-Za-z0-9_]*\\+?=";
+// Beyond Bash identifiers: GNU `env` accepts any `NAME=VALUE` operand (`TOKEN-NAME=x`),
+// so names admit dots and interior dashes. A leading dash stays excluded, keeping option
+// words (`--transport=stdio`, `-Dfoo.bar=x`) published. `+?=`: Bash `NAME+=value`
+// appends, or plainly sets an unset name, and exports the same way.
+const ASSIGNMENT_NAME = "[A-Za-z0-9_.][A-Za-z0-9_.-]*\\+?=";
const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}]+)+`;
const COMMAND_ENV_ASSIGNMENT = new RegExp(
`(^|[\\s${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
@@ -1236,6 +1239,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
// grammar treats as an assignment is not decidable here.
if (/\s/.test(unquoted)) return true;
if (!word.includes("=")) continue;
+ // A leading `=` is the tail of an assignment some expansion spliced apart
+ // (`env {TOK,EN}=x` breaks at the braces and leaves `=x`).
+ if (unquoted.startsWith("=")) return true;
// A quote-mangled `NAME=` spelling (`TOKEN\\=x`, `'TOKEN'=x`) for `env`/`eval`.
if (ASSIGNMENT_START.test(unquoted)) return true;
// A split-string option with its value attached (`-STOKEN=x`).
@@ -1599,10 +1605,13 @@ export async function createBackupPayload(
const leakedFiles = files
.filter((file) => {
// The path publishes alongside the content, and recursive collections take
- // whatever a directory entry happens to be named.
+ // whatever a directory entry happens to be named. The stripped variant catches a
+ // token split by shell quoting (`--token ghp_123\456...`): the shell removes the
+ // quoting on execution, and the published text reconstructs the same credential.
const content = file.content.toString("utf-8");
+ const stripped = content.replace(/[\\'"]/g, "");
return CREDENTIAL_TOKEN_PATTERNS.some(
- (pattern) => pattern.test(content) || pattern.test(file.path)
+ (pattern) => pattern.test(content) || pattern.test(stripped) || pattern.test(file.path)
);
})
.map((file) => file.path)
From 63528c4251716907d8bdf201b671651b9fc46d21 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 02:21:05 +0000
Subject: [PATCH 012/116] fix: model braces as word content and accept
arbitrary env operand assignment names
---
src/node/services/backup/payload.test.ts | 11 +++++++---
src/node/services/backup/payload.ts | 28 ++++++++++++++++--------
2 files changed, 27 insertions(+), 12 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 35f1903625f..30cee52cc09 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -379,9 +379,14 @@ describe("backup payload", () => {
["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`],
- // Brace expansion splices assignment fragments across word breaks.
- ["env {TOK,EN}=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
- ["env TOK{A,B}=hunter2 mcp-server", REDACTED_BACKUP_VALUE],
+ ["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`],
+ // After POSIX `--`, even an option-looking word is an env assignment operand.
+ ["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`],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 6337bb849fb..ca0e9271749 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1141,13 +1141,16 @@ function valueHasRedactionAtPath(
* 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.
-const SHELL_WORD_BREAK = ";&|<>(){}`";
-// Beyond Bash identifiers: GNU `env` accepts any `NAME=VALUE` operand (`TOKEN-NAME=x`),
-// so names admit dots and interior dashes. A leading dash stays excluded, keeping option
-// words (`--transport=stdio`, `-Dfoo.bar=x`) published. `+?=`: Bash `NAME+=value`
-// appends, or plainly sets an unset name, and exports the same way.
-const ASSIGNMENT_NAME = "[A-Za-z0-9_.][A-Za-z0-9_.-]*\\+?=";
+// 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 = ";&|<>()`";
+// 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 = `[^-\\s\\\\'"$=${SHELL_WORD_BREAK}][^\\s\\\\'"$=${SHELL_WORD_BREAK}]*=`;
const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}]+)+`;
const COMMAND_ENV_ASSIGNMENT = new RegExp(
`(^|[\\s${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
@@ -1230,7 +1233,14 @@ const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VAL
* about the rest of that word.
*/
function hasDisguisedAssignment(redacted: string): boolean {
+ let operandsOnly = false;
for (const word of redacted.match(SHELL_WORD) ?? []) {
+ // POSIX `--` ends option parsing: past it even a dash-led word is an operand, so
+ // `env -- --evil=x` sets an environment entry despite the option look.
+ if (word === "--") {
+ operandsOnly = true;
+ continue;
+ }
if (CONSUMED_ASSIGNMENT.test(word)) continue;
const unquoted = unquoteShellWord(word);
// A quoted region spanning whitespace is a script or argument string some
@@ -1239,8 +1249,8 @@ function hasDisguisedAssignment(redacted: string): boolean {
// grammar treats as an assignment is not decidable here.
if (/\s/.test(unquoted)) return true;
if (!word.includes("=")) continue;
- // A leading `=` is the tail of an assignment some expansion spliced apart
- // (`env {TOK,EN}=x` breaks at the braces and leaves `=x`).
+ if (operandsOnly) 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;
From 63d16b5c1fa1dceb5e9353eaec94cd79558eaf4d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 02:35:49 +0000
Subject: [PATCH 013/116] fix: recognize all env option terminator spellings,
scope quote-strip scan to command content
---
src/node/services/backup/payload.test.ts | 16 +++++++++++++-
src/node/services/backup/payload.ts | 28 ++++++++++++++----------
2 files changed, 32 insertions(+), 12 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 30cee52cc09..e6aea68205f 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -385,8 +385,11 @@ describe("backup payload", () => {
["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`],
- // After POSIX `--`, even an option-looking word is an env assignment operand.
+ // 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`],
@@ -565,6 +568,17 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
+ 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",
+ });
+ expect(payload.files.some((file) => file.path === "skills/demo/SKILL.md")).toBe(true);
+ });
+
it("blocks the export when a credential format appears in a published path", async () => {
const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await writeFixtureFile(muxRoot, `skills/${token}/SKILL.md`, "docs only\n");
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index ca0e9271749..52deba16134 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1235,14 +1235,16 @@ const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VAL
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
for (const word of redacted.match(SHELL_WORD) ?? []) {
- // POSIX `--` ends option parsing: past it even a dash-led word is an operand, so
- // `env -- --evil=x` sets an environment entry despite the option look.
- if (word === "--") {
+ if (CONSUMED_ASSIGNMENT.test(word)) continue;
+ const unquoted = unquoteShellWord(word);
+ // Option terminators end option parsing: past one even a dash-led word is an
+ // operand, so `env -- --evil=x` sets an environment entry despite the option look.
+ // GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
+ // after quote removal, so `"--"` and `\-\-` spellings count as well.
+ if (unquoted === "-" || unquoted === "--") {
operandsOnly = true;
continue;
}
- if (CONSUMED_ASSIGNMENT.test(word)) continue;
- const unquoted = unquoteShellWord(word);
// 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
@@ -1615,13 +1617,17 @@ export async function createBackupPayload(
const leakedFiles = files
.filter((file) => {
// The path publishes alongside the content, and recursive collections take
- // whatever a directory entry happens to be named. The stripped variant catches a
- // token split by shell quoting (`--token ghp_123\456...`): the shell removes the
- // quoting on execution, and the published text reconstructs the same credential.
+ // whatever a directory entry happens to be named.
const content = file.content.toString("utf-8");
- const stripped = content.replace(/[\\'"]/g, "");
- return CREDENTIAL_TOKEN_PATTERNS.some(
- (pattern) => pattern.test(content) || pattern.test(stripped) || pattern.test(file.path)
+ const targets = [content, file.path];
+ // The stripped variant catches a token split by shell quoting (`--token
+ // ghp_123\456...`): the shell removes the quoting on execution, and the published
+ // text reconstructs the same credential. Only command content is shell input;
+ // prose can legitimately hold quote-separated token-like fragments, and this
+ // block has no override.
+ if (file.path === "mcp.jsonc") targets.push(content.replace(/[\\'"]/g, ""));
+ return CREDENTIAL_TOKEN_PATTERNS.some((pattern) =>
+ targets.some((target) => pattern.test(target))
);
})
.map((file) => file.path)
From 904bd9d9d85129b293c4c8a4119972f18dc54d73 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 02:49:03 +0000
Subject: [PATCH 014/116] fix: accept env split-string abbreviations, scan
NUL-stripped content for UTF-16 tokens
---
src/node/services/backup/payload.test.ts | 24 +++++++++++++++++++++++-
src/node/services/backup/payload.ts | 23 ++++++++++++++++++-----
2 files changed, 41 insertions(+), 6 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index e6aea68205f..1be748b5d63 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -406,8 +406,11 @@ describe("backup payload", () => {
["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.
+ // 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],
// Expansion bodies can smuggle assignment bytes past every lexical check.
@@ -579,6 +582,25 @@ describe("backup payload", () => {
expect(payload.files.some((file) => file.path === "skills/demo/SKILL.md")).toBe(true);
});
+ it("blocks the export when a UTF-16 document carries a credential token", async () => {
+ const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ 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 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_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
await writeFixtureFile(muxRoot, `skills/${token}/SKILL.md`, "docs only\n");
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 52deba16134..4d44d070c15 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1220,8 +1220,18 @@ function unquoteShellWord(word: string): string {
return result;
}
-/** GNU `env -S`/`--split-string` re-splits its attached value into assignments. */
-const SPLIT_STRING_OPTION = /^-[A-Za-z]*S|^--split-string/;
+/**
+ * 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 {
+ if (/^-[A-Za-z]*S/.test(unquoted)) return true;
+ const abbreviation = /^--([A-Za-z-]*)=/.exec(unquoted);
+ return (
+ abbreviation !== null && abbreviation[1] !== "" && "split-string".startsWith(abbreviation[1])
+ );
+}
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
@@ -1256,8 +1266,8 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (unquoted.startsWith("=")) return true;
// A quote-mangled `NAME=` spelling (`TOKEN\\=x`, `'TOKEN'=x`) for `env`/`eval`.
if (ASSIGNMENT_START.test(unquoted)) return true;
- // A split-string option with its value attached (`-STOKEN=x`).
- if (SPLIT_STRING_OPTION.test(unquoted)) return true;
+ // A split-string option with its value attached (`-STOKEN=x`, `--s=TOKEN=x`).
+ if (isSplitStringOption(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;
@@ -1619,7 +1629,10 @@ export async function createBackupPayload(
// 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");
- const targets = [content, file.path];
+ // 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.
+ const targets = [content, content.replaceAll("\u0000", ""), file.path];
// The stripped variant catches a token split by shell quoting (`--token
// ghp_123\456...`): the shell removes the quoting on execution, and the published
// text reconstructs the same credential. Only command content is shell input;
From 0e6d4c7345e27cb60c63859317b23389c19e6130 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:08:00 +0000
Subject: [PATCH 015/116] fix: fail closed on expansion-brace groups in
unconsumed command words
---
src/node/services/backup/payload.test.ts | 5 +++++
src/node/services/backup/payload.ts | 9 +++++++++
2 files changed, 14 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 1be748b5d63..0ea9e09890b 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -385,6 +385,11 @@ describe("backup payload", () => {
["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,
+ ],
// 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],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 4d44d070c15..6dfa0a17e92 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1236,6 +1236,14 @@ function isSplitStringOption(unquoted: string): boolean {
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
+/**
+ * A brace group holding `,` or `..` expands, and expansion output can reassemble a
+ * credential from fragments no scanner recognizes (`ghp_...{8..8}...`). Literal braces
+ * (`{hunter2}`) do not expand and stay inside the word the ordinary rules cover. A
+ * consumed assignment is exempt: its braces expand into copies of the marker.
+ */
+const BRACE_EXPANSION = /\{[^{}]*(?:,|\.\.)[^{}]*\}/;
+
/**
* 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
@@ -1246,6 +1254,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
+ if (BRACE_EXPANSION.test(word)) return true;
const unquoted = unquoteShellWord(word);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
From a31f10927fc484074c072fb30696553e22e19dd3 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:17:29 +0000
Subject: [PATCH 016/116] fix: normalize parsed command strings for the
backstop, numeric env clusters, digit-gated sk- hard block
---
src/node/services/backup/payload.test.ts | 67 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 40 +++++++++++---
2 files changed, 99 insertions(+), 8 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 0ea9e09890b..7064a8c134c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -418,6 +418,7 @@ describe("backup payload", () => {
["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],
@@ -576,6 +577,72 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
+ it("blocks the export when a line continuation splits a known credential token", async () => {
+ // Bash removes backslash-newline entirely, handing the server one contiguous key.
+ const brokenKey = "AKIA12345678\\\n90123456";
+ // In a command the continuation-joined word spans whitespace and fails closed at
+ // the redactor, so the whole command goes local before any scan runs.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command: `mcp-grafana --key ${brokenKey}` } } })
+ );
+ const localized = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const mcp = jsonc.parse(payloadFileText(localized, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+
+ // Other portable strings publish verbatim, so the backstop must reassemble what
+ // Bash would join before matching.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { notes: { command: "npx notes-mcp", toolAllowlist: [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"]);
+ });
+
+ 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("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');
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 6dfa0a17e92..37dc14f2cea 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -74,7 +74,11 @@ function isHiddenName(name: string): boolean {
* defect, and neither is something a backup should publish.
*/
const CREDENTIAL_TOKEN_PATTERNS = [
- /\bsk-[A-Za-z0-9_-]{16,}\b/,
+ // The digit requirement keeps documentation placeholders (`sk-your-api-key-here`)
+ // out of this no-override block: issued keys are base62 and practically always carry
+ // digits, while placeholders are dash-separated words. The digit-free spelling stays
+ // in the reviewable scan below.
+ /\bsk-(?=[A-Za-z0-9_-]{16})[A-Za-z_-]*[0-9][A-Za-z0-9_-]*\b/,
/\bghp_[A-Za-z0-9]{20,}\b/,
/\bgho_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
@@ -87,6 +91,7 @@ const CREDENTIAL_TOKEN_PATTERNS = [
const SECRET_PATTERNS = [
...CREDENTIAL_TOKEN_PATTERNS,
+ /\bsk-[A-Za-z0-9_-]{16,}\b/,
/\bAIza[A-Za-z0-9_-]{35,}/,
/-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
] as const;
@@ -1226,7 +1231,7 @@ function unquoteShellWord(word: string): string {
* with `s`, so every `--s...` prefix spelling (`--s=`, `--split=`) resolves to it.
*/
function isSplitStringOption(unquoted: string): boolean {
- if (/^-[A-Za-z]*S/.test(unquoted)) return true;
+ if (/^-[A-Za-z0-9]*S/.test(unquoted)) return true;
const abbreviation = /^--([A-Za-z-]*)=/.exec(unquoted);
return (
abbreviation !== null && abbreviation[1] !== "" && "split-string".startsWith(abbreviation[1])
@@ -1642,12 +1647,18 @@ export async function createBackupPayload(
// interleaved NUL characters here; text published as prose has no business
// holding NULs, so this manufactures no match from ordinary content.
const targets = [content, content.replaceAll("\u0000", ""), file.path];
- // The stripped variant catches a token split by shell quoting (`--token
- // ghp_123\456...`): the shell removes the quoting on execution, and the published
- // text reconstructs the same credential. Only command content is shell input;
- // prose can legitimately hold quote-separated token-like fragments, and this
- // block has no override.
- if (file.path === "mcp.jsonc") targets.push(content.replace(/[\\'"]/g, ""));
+ // Shell-normalized variants catch a token split by quoting or a line
+ // continuation (`--token ghp_123\456...`, `AKIA...\...`): the shell
+ // removes both on execution, and the published text reconstructs the same
+ // credential. Normalization works on parsed string values, not the raw JSON
+ // text, whose escape encoding garbles the reassembly. Only command content is
+ // shell input; prose can legitimately hold quote-separated token-like
+ // fragments, and this block has no override.
+ if (file.path === "mcp.jsonc") {
+ for (const text of collectStringValues(jsonc.parse(content))) {
+ targets.push(text.replace(/\\\r?\n/g, "").replace(/[\\'"]/g, ""));
+ }
+ }
return CREDENTIAL_TOKEN_PATTERNS.some((pattern) =>
targets.some((target) => pattern.test(target))
);
@@ -2646,6 +2657,19 @@ function readUrl(server: Record | undefined): string | undefine
return typeof url === "string" ? url : undefined;
}
+/** Every string value in a parsed tree, for shell-normalized credential scanning. */
+function collectStringValues(value: unknown, found: string[] = []): string[] {
+ if (typeof value === "string") {
+ found.push(value);
+ } else if (Array.isArray(value)) {
+ for (const item of value) collectStringValues(item, found);
+ } else {
+ const record = readRecord(value);
+ if (record) for (const item of Object.values(record)) collectStringValues(item, found);
+ }
+ return found;
+}
+
/**
* 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,
From 85cb5da873829df17ec8239c24d5dcbb7ae50a3b Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:23:11 +0000
Subject: [PATCH 017/116] fix: scan expansion-stripped command strings so empty
expansions cannot splice tokens
---
src/node/services/backup/payload.test.ts | 23 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 6 +++++-
2 files changed, 28 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 7064a8c134c..f21c1f4e73e 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -619,6 +619,29 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
+ it("blocks the export when an empty expansion splices a known credential token", async () => {
+ // Bash expands the unset positional to nothing, joining the fragments.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ grafana: { command: "mcp-grafana --token ghp_1234567890$912345678901234567890" },
+ },
+ })
+ );
+ 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("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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 37dc14f2cea..57fb1c472a4 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1656,7 +1656,11 @@ export async function createBackupPayload(
// fragments, and this block has no override.
if (file.path === "mcp.jsonc") {
for (const text of collectStringValues(jsonc.parse(content))) {
- targets.push(text.replace(/\\\r?\n/g, "").replace(/[\\'"]/g, ""));
+ const joined = text.replace(/\\\r?\n/g, "").replace(/[\\'"]/g, "");
+ targets.push(joined);
+ // A simple parameter expansion that is unset at runtime vanishes
+ // (`ghp_...$9123...`), splicing the fragments around it into one token.
+ targets.push(joined.replace(/\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?!$-])/g, ""));
}
}
return CREDENTIAL_TOKEN_PATTERNS.some((pattern) =>
From 8844be27eb9e0ff751f8665b1274a00d35a31b84 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:29:10 +0000
Subject: [PATCH 018/116] fix: scope shell normalization to command strings so
non-command fields cannot manufacture blocks
---
src/node/services/backup/payload.test.ts | 23 +++++++---------
src/node/services/backup/payload.ts | 35 ++++++++++++------------
2 files changed, 27 insertions(+), 31 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index f21c1f4e73e..64840d00e07 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -577,7 +577,7 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
- it("blocks the export when a line continuation splits a known credential token", async () => {
+ it("localizes continuation-split command credentials and keeps non-command strings verbatim", async () => {
// Bash removes backslash-newline entirely, handing the server one contiguous key.
const brokenKey = "AKIA12345678\\\n90123456";
// In a command the continuation-joined word spans whitespace and fails closed at
@@ -598,8 +598,8 @@ describe("backup payload", () => {
};
expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
- // Other portable strings publish verbatim, so the backstop must reassemble what
- // Bash would join before matching.
+ // 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",
@@ -607,16 +607,13 @@ describe("backup payload", () => {
servers: { notes: { command: "npx notes-mcp", toolAllowlist: [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"]);
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("AKIA12345678");
});
it("blocks the export when an empty expansion splices a known credential token", async () => {
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 57fb1c472a4..8c1a910a22d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1647,15 +1647,15 @@ export async function createBackupPayload(
// interleaved NUL characters here; text published as prose has no business
// holding NULs, so this manufactures no match from ordinary content.
const targets = [content, content.replaceAll("\u0000", ""), file.path];
- // Shell-normalized variants catch a token split by quoting or a line
- // continuation (`--token ghp_123\456...`, `AKIA...\...`): the shell
- // removes both on execution, and the published text reconstructs the same
- // credential. Normalization works on parsed string values, not the raw JSON
- // text, whose escape encoding garbles the reassembly. Only command content is
- // shell input; prose can legitimately hold quote-separated token-like
- // fragments, and this block has no override.
+ // 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") {
- for (const text of collectStringValues(jsonc.parse(content))) {
+ for (const text of collectCommandStrings(jsonc.parse(content))) {
const joined = text.replace(/\\\r?\n/g, "").replace(/[\\'"]/g, "");
targets.push(joined);
// A simple parameter expansion that is unset at runtime vanishes
@@ -2661,17 +2661,16 @@ function readUrl(server: Record | undefined): string | undefine
return typeof url === "string" ? url : undefined;
}
-/** Every string value in a parsed tree, for shell-normalized credential scanning. */
-function collectStringValues(value: unknown, found: string[] = []): string[] {
- if (typeof value === "string") {
- found.push(value);
- } else if (Array.isArray(value)) {
- for (const item of value) collectStringValues(item, found);
- } else {
- const record = readRecord(value);
- if (record) for (const item of Object.values(record)) collectStringValues(item, found);
+/** 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 found;
+ return commands;
}
/**
From 9a403170831b5b3171eba5b2f4bc31f2d8857827 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:47:41 +0000
Subject: [PATCH 019/116] fix: linear sk- token check, bash-accurate quote
removal, block continuation-split command credentials
---
src/node/services/backup/payload.test.ts | 50 +++++++++++++-----
src/node/services/backup/payload.ts | 65 ++++++++++++++++++------
2 files changed, 86 insertions(+), 29 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 64840d00e07..3181262af2b 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -401,6 +401,7 @@ describe("backup payload", () => {
["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],
@@ -577,26 +578,25 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
- it("localizes continuation-split command credentials and keeps non-command strings verbatim", async () => {
- // Bash removes backslash-newline entirely, handing the server one contiguous key.
+ 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";
- // In a command the continuation-joined word spans whitespace and fails closed at
- // the redactor, so the whole command goes local before any scan runs.
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
JSON.stringify({ servers: { grafana: { command: `mcp-grafana --key ${brokenKey}` } } })
);
- const localized = await createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- });
- const mcp = jsonc.parse(payloadFileText(localized, "mcp.jsonc")) as {
- servers: { grafana: { command: string } };
- };
- expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
+ 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.
@@ -616,6 +616,28 @@ describe("backup payload", () => {
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("blocks the export when an empty expansion splices a known credential token", async () => {
// Bash expands the unset positional to nothing, joining the fragments.
await writeFixtureFile(
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 8c1a910a22d..d17a0c851ae 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -74,11 +74,6 @@ function isHiddenName(name: string): boolean {
* defect, and neither is something a backup should publish.
*/
const CREDENTIAL_TOKEN_PATTERNS = [
- // The digit requirement keeps documentation placeholders (`sk-your-api-key-here`)
- // out of this no-override block: issued keys are base62 and practically always carry
- // digits, while placeholders are dash-separated words. The digit-free spelling stays
- // in the reviewable scan below.
- /\bsk-(?=[A-Za-z0-9_-]{16})[A-Za-z_-]*[0-9][A-Za-z0-9_-]*\b/,
/\bghp_[A-Za-z0-9]{20,}\b/,
/\bgho_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
@@ -89,6 +84,27 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
] as const;
+/**
+ * The digit requirement keeps documentation placeholders (`sk-your-api-key-here`) out
+ * of the no-override block: issued keys are base62 and practically always carry digits,
+ * while placeholders are dash-separated words. The digit-free spelling stays in the
+ * reviewable scan. Checked per maximal candidate run instead of inside one regex, whose
+ * digit search would backtrack quadratically across a digit-free `sk-sk-...` wall in
+ * the synchronous scanner.
+ */
+function hasDigitBearingSkToken(text: string): boolean {
+ for (const match of text.matchAll(/\bsk-[A-Za-z0-9_-]{16,}\b/g)) {
+ if (/[0-9]/.test(match[0])) return true;
+ }
+ return false;
+}
+
+function matchesCredentialToken(text: string): boolean {
+ return (
+ CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(text)) || hasDigitBearingSkToken(text)
+ );
+}
+
const SECRET_PATTERNS = [
...CREDENTIAL_TOKEN_PATTERNS,
/\bsk-[A-Za-z0-9_-]{16,}\b/,
@@ -1180,9 +1196,10 @@ const SHELL_WORD = new RegExp(
const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
/**
- * Quote removal only, and simplified: every backslash escapes, including inside double
- * quotes where the shell keeps some. The difference only ever turns more words into
- * detected assignments, never fewer.
+ * 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).
*/
function unquoteShellWord(word: string): string {
let result = "";
@@ -1195,6 +1212,11 @@ function unquoteShellWord(word: string): string {
continue;
}
if (char === "\\") {
+ // A line continuation disappears entirely.
+ if (word[i + 1] === "\n" || (word[i + 1] === "\r" && word[i + 2] === "\n")) {
+ i += word[i + 1] === "\r" ? 3 : 2;
+ continue;
+ }
result += word[i + 1] ?? "";
i += 2;
continue;
@@ -1209,12 +1231,24 @@ function unquoteShellWord(word: string): string {
let j = i + 1;
while (j < word.length && word[j] !== '"') {
if (word[j] === "\\") {
- result += word[j + 1] ?? "";
- j += 2;
- } else {
+ 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;
@@ -1656,16 +1690,17 @@ export async function createBackupPayload(
// token-like fragments, and this block has no override.
if (file.path === "mcp.jsonc") {
for (const text of collectCommandStrings(jsonc.parse(content))) {
- const joined = text.replace(/\\\r?\n/g, "").replace(/[\\'"]/g, "");
+ // 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 joined = (text.match(SHELL_WORD) ?? []).map(unquoteShellWord).join(" ");
targets.push(joined);
// A simple parameter expansion that is unset at runtime vanishes
// (`ghp_...$9123...`), splicing the fragments around it into one token.
targets.push(joined.replace(/\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?!$-])/g, ""));
}
}
- return CREDENTIAL_TOKEN_PATTERNS.some((pattern) =>
- targets.some((target) => pattern.test(target))
- );
+ return targets.some(matchesCredentialToken);
})
.map((file) => file.path)
.sort();
From aee4b91118a93c92f8f9fbae5fb9ac980b1de409 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 03:56:03 +0000
Subject: [PATCH 020/116] fix: fail closed on option values embedding
assignments
---
src/node/services/backup/payload.test.ts | 8 ++++++++
src/node/services/backup/payload.ts | 10 ++++++++++
2 files changed, 18 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 3181262af2b..65cf1adc717 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -390,6 +390,14 @@ describe("backup payload", () => {
"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 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],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d17a0c851ae..3b3232b5c8e 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1316,6 +1316,16 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (ASSIGNMENT_START.test(unquoted)) return true;
// A split-string option with its value attached (`-STOKEN=x`, `--s=TOKEN=x`).
if (isSplitStringOption(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 flag values (`--transport=stdio`)
+ // carry no inner `=` and stay published.
+ if (
+ unquoted.startsWith("-") &&
+ ASSIGNMENT_START.test(unquoted.slice(unquoted.indexOf("=") + 1))
+ ) {
+ 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;
From b6c24f246c304d8e39b6a1df85698a7e096f0532 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 04:04:37 +0000
Subject: [PATCH 021/116] fix: strip parameter expansions only in active
quoting contexts
---
src/node/services/backup/payload.test.ts | 46 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 30 +++++++++++++---
2 files changed, 71 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 65cf1adc717..211b979b8d5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -646,6 +646,52 @@ describe("backup payload", () => {
}
});
+ it("keeps inert dollar literals from manufacturing tokens while active ones splice", async () => {
+ // Single-quoted and escaped dollars reach the process literally, and even an
+ // active `$NAME` swallows the letters behind it into the variable name, 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$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");
+ }
+
+ // A positional expansion ends at one character, so its empty expansion joins
+ // the digit tail back onto the prefix inside active double quotes.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: 'mcp --pattern "ghp_aaaaaaaaaa$912345678901234567890"' } },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ });
+
it("blocks the export when an empty expansion splices a known credential token", async () => {
// Bash expands the unset positional to nothing, joining the fragments.
await writeFixtureFile(
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3b3232b5c8e..93472154600 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1195,13 +1195,18 @@ const SHELL_WORD = new RegExp(
);
const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
+/** A parameter expansion an unset variable turns into nothing at runtime. */
+const SIMPLE_EXPANSION = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-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).
+ * 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): string {
+function unquoteShellWord(word: string, stripExpansions = false): string {
let result = "";
let i = 0;
while (i < word.length) {
@@ -1211,6 +1216,13 @@ function unquoteShellWord(word: string): string {
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.
if (word[i + 1] === "\n" || (word[i + 1] === "\r" && word[i + 2] === "\n")) {
@@ -1230,6 +1242,14 @@ function unquoteShellWord(word: string): string {
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
@@ -1703,11 +1723,11 @@ export async function createBackupPayload(
// 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 joined = (text.match(SHELL_WORD) ?? []).map(unquoteShellWord).join(" ");
- targets.push(joined);
+ const words = text.match(SHELL_WORD) ?? [];
+ targets.push(words.map((word) => unquoteShellWord(word)).join(" "));
// A simple parameter expansion that is unset at runtime vanishes
// (`ghp_...$9123...`), splicing the fragments around it into one token.
- targets.push(joined.replace(/\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?!$-])/g, ""));
+ targets.push(words.map((word) => unquoteShellWord(word, true)).join(" "));
}
}
return targets.some(matchesCredentialToken);
From 1e2a5534a2265369f007aaddfa2a60493681542e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 04:17:22 +0000
Subject: [PATCH 022/116] fix: CRLF is not a continuation, example AWS key
stays reviewable, attached short-option assignments fail closed, preview
refreshes scan state
---
.../Settings/Sections/BackupSection.tsx | 5 +++
src/node/services/backup/payload.test.ts | 41 +++++++++++++++++++
src/node/services/backup/payload.ts | 26 +++++++++---
tests/ui/BackupSection.test.ts | 32 +++++++++++++++
4 files changed, 98 insertions(+), 6 deletions(-)
diff --git a/src/browser/features/Settings/Sections/BackupSection.tsx b/src/browser/features/Settings/Sections/BackupSection.tsx
index 1e7504ee7be..8b0580256e6 100644
--- a/src/browser/features/Settings/Sections/BackupSection.tsx
+++ b/src/browser/features/Settings/Sections/BackupSection.tsx
@@ -364,6 +364,11 @@ export function BackupSection() {
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);
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 211b979b8d5..9e0b3052699 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -393,6 +393,8 @@ describe("backup payload", () => {
// 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",
@@ -646,6 +648,29 @@ describe("backup payload", () => {
}
});
+ 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, and even an
// active `$NAME` swallows the letters behind it into the variable name, so none
@@ -715,6 +740,22 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
+ 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 93472154600..b66f8f01042 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -99,9 +99,18 @@ function hasDigitBearingSkToken(text: string): boolean {
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";
+
function matchesCredentialToken(text: string): boolean {
+ const scannable = text.replaceAll(EXAMPLE_ACCESS_KEY, " ");
return (
- CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(text)) || hasDigitBearingSkToken(text)
+ CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(scannable)) ||
+ hasDigitBearingSkToken(scannable)
);
}
@@ -1224,9 +1233,10 @@ function unquoteShellWord(word: string, stripExpansions = false): string {
}
}
if (char === "\\") {
- // A line continuation disappears entirely.
- if (word[i + 1] === "\n" || (word[i + 1] === "\r" && word[i + 2] === "\n")) {
- i += word[i + 1] === "\r" ? 3 : 2;
+ // 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] ?? "";
@@ -1338,14 +1348,18 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (isSplitStringOption(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 flag values (`--transport=stdio`)
- // carry no inner `=` and stay published.
+ // 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;
diff --git a/tests/ui/BackupSection.test.ts b/tests/ui/BackupSection.test.ts
index 67cb56d9269..c06ea12c04f 100644
--- a/tests/ui/BackupSection.test.ts
+++ b/tests/ui/BackupSection.test.ts
@@ -403,6 +403,38 @@ describe("BackupSection", () => {
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("sends the approved digest and resets when the blocked payload changes", async () => {
const { client, view } = renderBackupSection();
const canvas = within(view.container);
From 6fa93efaef15ca301c589dc867934adae45e4cc0 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 04:48:30 +0000
Subject: [PATCH 023/116] fix: bash-accurate word breaks, live special params,
comments, url percent-decode, and deterministic glob collapse in credential
scans
---
src/node/services/backup/payload.test.ts | 163 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 114 ++++++++++++++--
2 files changed, 266 insertions(+), 11 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 9e0b3052699..7ee2f25000c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -740,6 +740,169 @@ describe("backup payload", () => {
expect((blocked as BackupCredentialDetectedError).files).toEqual(["mcp.jsonc"]);
});
+ 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("keeps always-set special parameters from manufacturing a credential block", async () => {
+ // Under `bash -c`, $0 is the shell name: the fragments never join at runtime, and
+ // the published `$` keeps the run broken for the scan, so creation must succeed.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: { grafana: { command: "mcp --pattern ghp_aaaaaaaaaa$0bbbbbbbbbb" } },
+ })
+ );
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa$0bbbbbbbbbb");
+ });
+
+ 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"');
+
+ // 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_%61aaaaaaaaaaaaaaaaaaa" } },
+ })
+ );
+ 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_%2561aaaaaaaaaaaaaaaaaaa" },
+ },
+ })
+ );
+ 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 () => {
+ // `[b]` matches only `b` and `?` exactly one character: pathname expansion can hand
+ // the process the contiguous token, and the published text collapses the same way
+ // for any reader.
+ for (const command of [
+ "mcp --pattern ghp_aaaaaaaaaa[b]aaaaaaaaa",
+ "mcp --pattern ghp_aaaaaaaaaa?aaaaaaaaa",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ }
+
+ // 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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index b66f8f01042..d674a5024b5 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1176,14 +1176,18 @@ function valueHasRedactionAtPath(
// 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 = `[^-\\s\\\\'"$=${SHELL_WORD_BREAK}][^\\s\\\\'"$=${SHELL_WORD_BREAK}]*=`;
-const ASSIGNMENT_VALUE = `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}]+)+`;
+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(
- `(^|[\\s${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
+ `(^|[${SHELL_BLANK}${SHELL_WORD_BREAK}])(${ASSIGNMENT_NAME})(${ASSIGNMENT_VALUE})`,
"g"
);
@@ -1193,19 +1197,25 @@ const COMMAND_ENV_ASSIGNMENT = new RegExp(
* `=`. Either way the value's true extent is unknowable, e.g. an unterminated quote.
*/
const UNCONSUMED_ASSIGNMENT = new RegExp(
- `(^|[\\s${SHELL_WORD_BREAK}])${ASSIGNMENT_NAME}` +
- `(?!${REDACTED_BACKUP_VALUE}(?=[\\s${SHELL_WORD_BREAK}]|$))(?=[^\\s${SHELL_WORD_BREAK}])`
+ `(^|[${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]|[^"\\\\])*"|[^\\s\\\\'"${SHELL_WORD_BREAK}])+`,
+ `(?:\\\\[\\s\\S]|'[^']*'|"(?:\\\\[\\s\\S]|[^"\\\\])*"|[^${SHELL_BLANK}\\\\'"${SHELL_WORD_BREAK}])+`,
"g"
);
const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
-/** A parameter expansion an unset variable turns into nothing at runtime. */
-const SIMPLE_EXPANSION = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?!$-])/;
+/**
+ * A parameter expansion that can turn into nothing at runtime: an unset variable, a
+ * positional this runtime never passes, or `$@`/`$*`/`$!` in a fresh shell. The specials
+ * Bash always fills under `-c` ($0, $?, $#, $$, $-) stay literal instead: their `$`
+ * spelling already breaks a token run for the scan, and deleting a value the runtime
+ * inserts would manufacture no-override matches from fragments that never join.
+ */
+const SIMPLE_EXPANSION = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[1-9@*!])/;
/**
* Bash-accurate quote removal, in both directions on purpose: under-stripping would hide
@@ -1215,7 +1225,7 @@ const SIMPLE_EXPANSION = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[0-9@*#?!$-])/;
* 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): string {
+function unquoteShellWord(word: string, stripExpansions = false, collapseGlobs = false): string {
let result = "";
let i = 0;
while (i < word.length) {
@@ -1283,12 +1293,62 @@ function unquoteShellWord(word: string, stripExpansions = false): string {
i = j + 1;
continue;
}
+ if (collapseGlobs) {
+ // Pathname expansion is live in this unquoted context. A single-member class is
+ // deterministic (`[8]` can only produce `8`), and any reader collapses the
+ // published spelling the same way, so scan what it yields. `?` scans as a
+ // representative member and `*` as its empty match for the same reason.
+ if (char === "[" && word[i + 2] === "]" && !"!^".includes(word[i + 1] ?? "")) {
+ result += word[i + 1];
+ i += 3;
+ continue;
+ }
+ if (char === "?") {
+ result += "0";
+ i += 1;
+ continue;
+ }
+ if (char === "*") {
+ i += 1;
+ 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] ?? "");
+ if (
+ match[0].startsWith("#") &&
+ (start === 0 || before === " " || before === "\t" || before === "\n")
+ ) {
+ 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
@@ -1733,15 +1793,25 @@ export async function createBackupPayload(
// 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") {
- for (const text of collectCommandStrings(jsonc.parse(content))) {
+ 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 = text.match(SHELL_WORD) ?? [];
+ const words = executedShellWords(text);
targets.push(words.map((word) => unquoteShellWord(word)).join(" "));
// A simple parameter expansion that is unset at runtime vanishes
// (`ghp_...$9123...`), splicing the fragments around it into one token.
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.
+ for (const url of collectUrlStrings(parsedMcp)) {
+ targets.push(percentDecodeOnce(url));
}
}
return targets.some(matchesCredentialToken);
@@ -2752,6 +2822,28 @@ function collectCommandStrings(root: unknown): string[] {
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 percentDecodeOnce(text: string): string {
+ return text.replace(/%([0-9a-fA-F]{2})/g, (_match, hex: string) =>
+ String.fromCharCode(Number.parseInt(hex, 16))
+ );
+}
+
/**
* 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,
From 418cbb784cea7e2e4a5003b90190b8f92c67ce9a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 05:07:06 +0000
Subject: [PATCH 024/116] fix: localize multi-member glob classes and
state-dependent $!, comments after operators, quoted braces stay portable
---
src/node/services/backup/payload.test.ts | 89 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 66 ++++++++++++++++--
2 files changed, 151 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 7ee2f25000c..3fac9d66a39 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -903,6 +903,95 @@ describe("backup payload", () => {
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("recognizes comments opened by a word break, not only by whitespace", async () => {
+ // 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 the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d674a5024b5..4bcfda6ed72 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1335,9 +1335,16 @@ function executedShellWords(text: string): string[] {
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.
if (
match[0].startsWith("#") &&
- (start === 0 || before === " " || before === "\t" || before === "\n")
+ (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);
@@ -1365,6 +1372,51 @@ function isSplitStringOption(unquoted: string): boolean {
/** 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 class with more than one member expands against whatever the working directory
+ * contains, so its output is not decidable here, unlike the single-member class the
+ * scan collapses deterministically. Ranges and negations are multi-member spellings.
+ */
+const MULTI_MEMBER_GLOB_CLASS = /\[[^\]]{2,}\]/;
+
/**
* A brace group holding `,` or `..` expands, and expansion output can reassemble a
* credential from fragments no scanner recognizes (`ghp_...{8..8}...`). Literal braces
@@ -1383,7 +1435,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
- if (BRACE_EXPANSION.test(word)) return true;
+ // Both tests run on the active projection: Bash expands neither syntax from
+ // quoted or escaped text (`--config '{"a":1,"b":2}'` stays a literal argument).
+ const active = activeWordProjection(word);
+ if (BRACE_EXPANSION.test(active)) return true;
+ if (MULTI_MEMBER_GLOB_CLASS.test(active)) return true;
const unquoted = unquoteShellWord(word);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
@@ -1431,9 +1487,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
* An expansion body can carry arbitrary bytes into one runtime word (`TOKEN$(printf
* =hunter2)`, `$'TOKEN\x3d...'`, legacy arithmetic `TOKEN$[0]=...`), so its mere
* presence makes assignment detection undecidable, whether or not the grammar matched
- * an assignment elsewhere.
+ * an assignment elsewhere. `$!` rides along because its value depends on execution
+ * state: empty until the command string itself starts a background job, a PID after,
+ * so neither the scan's empty assumption nor a literal reading holds for both.
*/
-const CARRIER_EXPANSION = /\$\(|\$\{|\$\[|\$'|\$"|`/;
+const CARRIER_EXPANSION = /\$\(|\$\{|\$\[|\$'|\$"|\$!|`/;
/** Process substitution passes bytes by file, ambiguous once an assignment matched. */
const PROCESS_SUBSTITUTION = /<\(|>\(/;
From e3af002c527bad24398f061863e8f8648874cc59 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 05:27:03 +0000
Subject: [PATCH 025/116] fix: localize nondeterministic wildcards so a prefix
glob cannot reconstruct a credential
---
src/node/services/backup/payload.test.ts | 64 ++++++++++++++++--------
src/node/services/backup/payload.ts | 25 ++++-----
2 files changed, 52 insertions(+), 37 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 3fac9d66a39..78908570b01 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -863,28 +863,24 @@ describe("backup payload", () => {
});
it("collapses deterministic globs so a bracketed spelling cannot hide a token", async () => {
- // `[b]` matches only `b` and `?` exactly one character: pathname expansion can hand
- // the process the contiguous token, and the published text collapses the same way
- // for any reader.
- for (const command of [
- "mcp --pattern ghp_aaaaaaaaaa[b]aaaaaaaaa",
- "mcp --pattern ghp_aaaaaaaaaa?aaaaaaaaa",
- ]) {
- await writeFixtureFile(
+ // `[b]` matches only `b`: 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[b]aaaaaaaaa" } },
+ })
+ );
+ const blocked = await captureRejection(
+ createBackupPayload({
muxRoot,
- "mcp.jsonc",
- JSON.stringify({ servers: { grafana: { command } } })
- );
- const blocked = await captureRejection(
- createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- })
- );
- expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
- }
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ })
+ );
+ expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
// Quoting suppresses pathname expansion, so the same spelling stays publishable.
await writeFixtureFile(
@@ -926,6 +922,32 @@ describe("backup payload", () => {
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("recognizes comments opened by a word break, not only by whitespace", async () => {
// 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 4bcfda6ed72..6f7e1b26688 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1296,22 +1296,13 @@ function unquoteShellWord(word: string, stripExpansions = false, collapseGlobs =
if (collapseGlobs) {
// Pathname expansion is live in this unquoted context. A single-member class is
// deterministic (`[8]` can only produce `8`), and any reader collapses the
- // published spelling the same way, so scan what it yields. `?` scans as a
- // representative member and `*` as its empty match for the same reason.
+ // published spelling the same way, so scan what it yields. Nondeterministic
+ // wildcards never reach this scan: redaction localizes their whole command.
if (char === "[" && word[i + 2] === "]" && !"!^".includes(word[i + 1] ?? "")) {
result += word[i + 1];
i += 3;
continue;
}
- if (char === "?") {
- result += "0";
- i += 1;
- continue;
- }
- if (char === "*") {
- i += 1;
- continue;
- }
}
result += char;
i += 1;
@@ -1411,11 +1402,13 @@ function activeWordProjection(word: string): string {
}
/**
- * A class with more than one member expands against whatever the working directory
- * contains, so its output is not decidable here, unlike the single-member class the
- * scan collapses deterministically. Ranges and negations are multi-member spellings.
+ * A glob whose output depends on the working directory: `?`, `*`, and any class with
+ * more than one member (ranges and negations included) expand against whatever files
+ * exist, so a wildcard inside a known token prefix (`gh?_...`) can hand the process a
+ * credential no textual scan reconstructs. Only the single-member class is
+ * deterministic, and the scan collapses that one instead.
*/
-const MULTI_MEMBER_GLOB_CLASS = /\[[^\]]{2,}\]/;
+const NONDETERMINISTIC_GLOB = /[?*]|\[[^\]]{2,}\]/;
/**
* A brace group holding `,` or `..` expands, and expansion output can reassemble a
@@ -1439,7 +1432,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// quoted or escaped text (`--config '{"a":1,"b":2}'` stays a literal argument).
const active = activeWordProjection(word);
if (BRACE_EXPANSION.test(active)) return true;
- if (MULTI_MEMBER_GLOB_CLASS.test(active)) return true;
+ if (NONDETERMINISTIC_GLOB.test(active)) return true;
const unquoted = unquoteShellWord(word);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
From 230be731332e6189ba14394696b77d5c4ed93a48 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 05:42:56 +0000
Subject: [PATCH 026/116] fix: linear quote-aware glob analyzer, escaped class
members localize, heredocs go machine-local
---
src/node/services/backup/payload.test.ts | 72 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 71 +++++++++++++++++++----
2 files changed, 131 insertions(+), 12 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 78908570b01..0e1782a80b6 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -948,6 +948,78 @@ describe("backup payload", () => {
}
});
+ 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 6f7e1b26688..b0fff7e7075 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1298,7 +1298,7 @@ function unquoteShellWord(word: string, stripExpansions = false, collapseGlobs =
// deterministic (`[8]` can only produce `8`), and any reader collapses the
// published spelling the same way, so scan what it yields. Nondeterministic
// wildcards never reach this scan: redaction localizes their whole command.
- if (char === "[" && word[i + 2] === "]" && !"!^".includes(word[i + 1] ?? "")) {
+ if (char === "[" && word[i + 2] === "]" && !"!^]\\'\"".includes(word[i + 1] ?? "")) {
result += word[i + 1];
i += 3;
continue;
@@ -1402,13 +1402,50 @@ function activeWordProjection(word: string): string {
}
/**
- * A glob whose output depends on the working directory: `?`, `*`, and any class with
- * more than one member (ranges and negations included) expand against whatever files
- * exist, so a wildcard inside a known token prefix (`gh?_...`) can hand the process a
- * credential no textual scan reconstructs. Only the single-member class is
- * deterministic, and the scan collapses that one instead.
+ * 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.
*/
-const NONDETERMINISTIC_GLOB = /[?*]|\[[^\]]{2,}\]/;
+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 === "[") {
+ if (word[i + 2] === "]" && !"!^]\\'\"".includes(word[i + 1] ?? "")) {
+ i += 3;
+ continue;
+ }
+ return true;
+ }
+ i += 1;
+ }
+ return false;
+}
/**
* A brace group holding `,` or `..` expands, and expansion output can reassemble a
@@ -1428,11 +1465,12 @@ function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
- // Both tests run on the active projection: Bash expands neither syntax from
- // quoted or escaped text (`--config '{"a":1,"b":2}'` stays a literal argument).
- const active = activeWordProjection(word);
- if (BRACE_EXPANSION.test(active)) return true;
- if (NONDETERMINISTIC_GLOB.test(active)) return true;
+ // 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 (BRACE_EXPANSION.test(activeWordProjection(word))) return true;
+ if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
@@ -1489,6 +1527,14 @@ const CARRIER_EXPANSION = /\$\(|\$\{|\$\[|\$'|\$"|\$!|`/;
/** Process substitution passes bytes by file, ambiguous once an assignment matched. */
const PROCESS_SUBSTITUTION = /<\(|>\(/;
+/**
+ * A here-document or here-string feeds the consumer a body whose text follows document
+ * rules, not word rules: quotes stay literal while expansions still run for an unquoted
+ * delimiter. The word-based scans would misread either direction, so the construct is
+ * one more script-input spelling that goes machine-local wholesale.
+ */
+const HEREDOC_REDIRECT = "<<";
+
function redactCommandEnvAssignments(command: string): string {
const redacted = command.replace(
COMMAND_ENV_ASSIGNMENT,
@@ -1502,6 +1548,7 @@ function redactCommandEnvAssignments(command: string): string {
UNCONSUMED_ASSIGNMENT.test(redacted) ||
hasDisguisedAssignment(redacted) ||
CARRIER_EXPANSION.test(command) ||
+ command.includes(HEREDOC_REDIRECT) ||
(redacted !== command && PROCESS_SUBSTITUTION.test(command))
) {
return REDACTED_BACKUP_VALUE;
From 16537e5d276f6b2903a7c09a3ef78addca77af83 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 06:08:04 +0000
Subject: [PATCH 027/116] fix: detect shell constructs only in active regions,
keep repeated-run credential placeholders reviewable
---
src/node/services/backup/payload.test.ts | 88 +++++++++++++++--
src/node/services/backup/payload.ts | 118 ++++++++++++++++++-----
2 files changed, 178 insertions(+), 28 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 0e1782a80b6..42a19fd3fa0 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -537,7 +537,7 @@ describe("backup payload", () => {
});
it("blocks the export outright when a credential pattern survives redaction", async () => {
- const token = "glsa_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa_00000000";
+ const token = "glsa_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6_00000000";
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
@@ -570,7 +570,7 @@ describe("backup payload", () => {
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_aaaaaaaaaaaaaaaaaa\\aaaaaaaaaaaaaaaaaa";
+ const brokenToken = "ghp_a1b2c3d4e5f6g7h8i9\\j0k1l2m3n4o5p6q7r8";
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
@@ -830,7 +830,7 @@ describe("backup payload", () => {
muxRoot,
"mcp.jsonc",
JSON.stringify({
- servers: { grafana: { url: "https://example.com/mcp?value=ghp_%61aaaaaaaaaaaaaaaaaaa" } },
+ servers: { grafana: { url: "https://example.com/mcp?value=ghp_%61b2c3d4e5f6g7h8i9j0k" } },
})
);
const blocked = await captureRejection(
@@ -849,7 +849,7 @@ describe("backup payload", () => {
"mcp.jsonc",
JSON.stringify({
servers: {
- grafana: { url: "https://example.com/mcp?value=ghp_%2561aaaaaaaaaaaaaaaaaaa" },
+ grafana: { url: "https://example.com/mcp?value=ghp_%2561b2c3d4e5f6g7h8i9j0k" },
},
})
);
@@ -1086,6 +1086,82 @@ describe("backup payload", () => {
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 # 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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
@@ -1138,7 +1214,7 @@ describe("backup payload", () => {
});
it("blocks the export when a UTF-16 document carries a credential token", async () => {
- const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const token = "ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8";
await fs.mkdir(path.join(muxRoot, "skills", "demo"), { recursive: true });
await fs.writeFile(
path.join(muxRoot, "skills", "demo", "SKILL.md"),
@@ -1157,7 +1233,7 @@ describe("backup payload", () => {
});
it("blocks the export when a credential format appears in a published path", async () => {
- const token = "ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+ const token = "ghp_a1b2c3d4e5f6g7h8i9j0k1l2m3n4o5p6q7r8";
await writeFixtureFile(muxRoot, `skills/${token}/SKILL.md`, "docs only\n");
const blocked = await captureRejection(
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index b0fff7e7075..5d0cf019410 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -106,8 +106,17 @@ function hasDigitBearingSkToken(text: string): boolean {
*/
const EXAMPLE_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
+/**
+ * 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.
+ */
+const PLACEHOLDER_RUN = /(.)\1{15,}/gi;
+
function matchesCredentialToken(text: string): boolean {
- const scannable = text.replaceAll(EXAMPLE_ACCESS_KEY, " ");
+ const scannable = text.replaceAll(EXAMPLE_ACCESS_KEY, " ").replace(PLACEHOLDER_RUN, " ");
return (
CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(scannable)) ||
hasDigitBearingSkToken(scannable)
@@ -1515,25 +1524,89 @@ function hasDisguisedAssignment(redacted: string): boolean {
}
/**
- * An expansion body can carry arbitrary bytes into one runtime word (`TOKEN$(printf
- * =hunter2)`, `$'TOKEN\x3d...'`, legacy arithmetic `TOKEN$[0]=...`), so its mere
- * presence makes assignment detection undecidable, whether or not the grammar matched
- * an assignment elsewhere. `$!` rides along because its value depends on execution
- * state: empty until the command string itself starts a background job, a PID after,
- * so neither the scan's empty assumption nor a literal reading holds for both.
+ * 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 `$!` depends on
+ * execution state, so 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 passes bytes by file, ambiguous once an
+ * assignment matched. 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.
*/
-const CARRIER_EXPANSION = /\$\(|\$\{|\$\[|\$'|\$"|\$!|`/;
-
-/** Process substitution passes bytes by file, ambiguous once an assignment matched. */
-const PROCESS_SUBSTITUTION = /<\(|>\(/;
-
-/**
- * A here-document or here-string feeds the consumer a body whose text follows document
- * rules, not word rules: quotes stay literal while expansions still run for an unquoted
- * delimiter. The word-based scans would misread either direction, so the construct is
- * one more script-input spelling that goes machine-local wholesale.
- */
-const HEREDOC_REDIRECT = "<<";
+function findActiveShellConstructs(command: string): {
+ carrier: boolean;
+ heredoc: boolean;
+ processSubstitution: boolean;
+} {
+ const found = { carrier: false, heredoc: false, processSubstitution: false };
+ let i = 0;
+ let wordStart = true;
+ while (i < command.length) {
+ const char = command[i];
+ if (char === "\\") {
+ i += 2;
+ wordStart = false;
+ continue;
+ }
+ if (char === "'") {
+ const end = command.indexOf("'", i + 1);
+ i = end === -1 ? command.length : end + 1;
+ wordStart = false;
+ 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] === "$" && "({[!".includes(command[j + 1] ?? "")) found.carrier = true;
+ j += 1;
+ }
+ i = j + 1;
+ wordStart = false;
+ continue;
+ }
+ if (char === "#" && wordStart) {
+ const lineEnd = command.indexOf("\n", i);
+ if (lineEnd === -1) break;
+ i = lineEnd + 1;
+ wordStart = true;
+ continue;
+ }
+ if (char === "`") {
+ found.carrier = true;
+ i += 1;
+ wordStart = false;
+ continue;
+ }
+ if (char === "$") {
+ if ("({['\"!".includes(command[i + 1] ?? "")) found.carrier = true;
+ i += 1;
+ wordStart = false;
+ continue;
+ }
+ if (char === "<") {
+ if (command[i + 1] === "<") found.heredoc = true;
+ if (command[i + 1] === "(") found.processSubstitution = true;
+ i += 1;
+ wordStart = true;
+ continue;
+ }
+ if (char === ">") {
+ if (command[i + 1] === "(") found.processSubstitution = true;
+ i += 1;
+ wordStart = true;
+ continue;
+ }
+ wordStart = char === " " || char === "\t" || char === "\n" || SHELL_WORD_BREAK.includes(char);
+ i += 1;
+ }
+ return found;
+}
function redactCommandEnvAssignments(command: string): string {
const redacted = command.replace(
@@ -1544,12 +1617,13 @@ function redactCommandEnvAssignments(command: string): string {
// 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(command);
if (
UNCONSUMED_ASSIGNMENT.test(redacted) ||
hasDisguisedAssignment(redacted) ||
- CARRIER_EXPANSION.test(command) ||
- command.includes(HEREDOC_REDIRECT) ||
- (redacted !== command && PROCESS_SUBSTITUTION.test(command))
+ constructs.carrier ||
+ constructs.heredoc ||
+ (redacted !== command && constructs.processSubstitution)
) {
return REDACTED_BACKUP_VALUE;
}
From fe5fa07aa44f222ff8c666038256203067bcfed2 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 06:12:13 +0000
Subject: [PATCH 028/116] fix: strip WHATWG tab and newline separators from
urls before the credential backstop
---
src/node/services/backup/payload.test.ts | 23 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 9 +++++++--
2 files changed, 30 insertions(+), 2 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 42a19fd3fa0..03d627492ec 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1162,6 +1162,29 @@ describe("backup payload", () => {
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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 5d0cf019410..eb97e9b233c 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1981,9 +1981,14 @@ export async function createBackupPayload(
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.
+ // 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)) {
- targets.push(percentDecodeOnce(url));
+ const canonical = url.replaceAll("\t", "").replaceAll("\n", "").replaceAll("\r", "");
+ targets.push(percentDecodeOnce(canonical));
}
}
return targets.some(matchesCredentialToken);
From 73aba1ae63c0d1406d1049865a17589a74bd0ea0 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 06:28:21 +0000
Subject: [PATCH 029/116] fix: nested brace expansion localizes, comment prose
survives assignment redaction
---
src/node/services/backup/payload.test.ts | 79 +++++++++++++++++++
src/node/services/backup/payload.ts | 98 ++++++++++++++++++++----
2 files changed, 164 insertions(+), 13 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 03d627492ec..84fafd98ae6 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1185,6 +1185,85 @@ describe("backup payload", () => {
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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index eb97e9b233c..841c7cff4b7 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1457,12 +1457,31 @@ function hasNondeterministicGlob(word: string): boolean {
}
/**
- * A brace group holding `,` or `..` expands, and expansion output can reassemble a
- * credential from fragments no scanner recognizes (`ghp_...{8..8}...`). Literal braces
- * (`{hunter2}`) do not expand and stay inside the word the ordinary rules cover. A
- * consumed assignment is exempt: its braces expand into copies of the marker.
+ * 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.
*/
-const BRACE_EXPANSION = /\{[^{}]*(?:,|\.\.)[^{}]*\}/;
+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;
+}
/**
* Words that hand a downstream consumer an assignment the shell itself does not see,
@@ -1478,7 +1497,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// '{"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 (BRACE_EXPANSION.test(activeWordProjection(word))) return true;
+ if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
// Option terminators end option parsing: past one even a dash-led word is an
@@ -1608,26 +1627,79 @@ function findActiveShellConstructs(command: string): {
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);
+ i += 2;
+ wordStart = false;
+ 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;
+}
+
function redactCommandEnvAssignments(command: string): string {
- const redacted = command.replace(
- COMMAND_ENV_ASSIGNMENT,
- (_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
+ const pieces = splitCommandComments(command);
+ const redactedPieces = pieces.map((piece) =>
+ piece.code.replace(
+ COMMAND_ENV_ASSIGNMENT,
+ (_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
+ )
);
+ const code = pieces.map((piece) => piece.code).join("");
+ 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(command);
if (
- UNCONSUMED_ASSIGNMENT.test(redacted) ||
- hasDisguisedAssignment(redacted) ||
+ UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
+ hasDisguisedAssignment(redactedCode) ||
constructs.carrier ||
constructs.heredoc ||
- (redacted !== command && constructs.processSubstitution)
+ (redactedCode !== code && constructs.processSubstitution)
) {
return REDACTED_BACKUP_VALUE;
}
- return redacted;
+ return redactedPieces.map((piece, index) => piece + (pieces[index]?.comment ?? "")).join("");
}
function redactMcpConfig(content: Buffer): {
From 5301e2bd255ffbf82c947c7327b1605742c2d0c1 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 06:45:07 +0000
Subject: [PATCH 030/116] fix: positional and list expansions localize because
the command can populate them first
---
src/node/services/backup/payload.test.ts | 52 ++++++++++++++++++------
src/node/services/backup/payload.ts | 20 +++++----
2 files changed, 51 insertions(+), 21 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 84fafd98ae6..4dfca348663 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -697,8 +697,8 @@ describe("backup payload", () => {
expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa");
}
- // A positional expansion ends at one character, so its empty expansion joins
- // the digit tail back onto the prefix inside active double quotes.
+ // A positional stays active inside double quotes, and the command itself could
+ // populate it first, so that spelling goes machine-local instead of publishing.
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
@@ -706,25 +706,27 @@ describe("backup payload", () => {
servers: { grafana: { command: 'mcp --pattern "ghp_aaaaaaaaaa$912345678901234567890"' } },
})
);
- const blocked = await captureRejection(
- createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- })
- );
- expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
+ 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 the export when an empty expansion splices a known credential token", async () => {
- // Bash expands the unset positional to nothing, joining the fragments.
+ // Bash expands the unset variable to nothing, joining the fragments across the
+ // quote boundary that ends its name.
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
JSON.stringify({
servers: {
- grafana: { command: "mcp-grafana --token ghp_1234567890$912345678901234567890" },
+ grafana: { command: 'mcp-grafana --token ghp_1234567890$NOPE"12345678901234567890"' },
},
})
);
@@ -1264,6 +1266,30 @@ describe("backup payload", () => {
);
});
+ 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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 841c7cff4b7..953b2a1f780 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1218,13 +1218,15 @@ const SHELL_WORD = new RegExp(
const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
/**
- * A parameter expansion that can turn into nothing at runtime: an unset variable, a
- * positional this runtime never passes, or `$@`/`$*`/`$!` in a fresh shell. The specials
- * Bash always fills under `-c` ($0, $?, $#, $$, $-) stay literal instead: their `$`
- * spelling already breaks a token run for the scan, and deleting a value the runtime
- * inserts would manufacture no-override matches from fragments that never join.
+ * A parameter expansion that can turn into nothing at runtime: an unset variable. No
+ * plain `$NAME` can gain a value mid-command without an assignment the redaction
+ * already rewrites. The specials Bash always fills under `-c` ($0, $?, $#, $$, $-)
+ * stay literal instead: their `$` spelling already breaks a token run for the scan,
+ * and deleting a value the runtime inserts would manufacture no-override matches from
+ * fragments that never join. Positionals and `$@`/`$*`/`$!` are carriers, because the
+ * same command string can populate them first (`set -- p`, `&`).
*/
-const SIMPLE_EXPANSION = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|[1-9@*!])/;
+const SIMPLE_EXPANSION = /^\$[A-Za-z_][A-Za-z0-9_]*/;
/**
* Bash-accurate quote removal, in both directions on purpose: under-stripping would hide
@@ -1582,7 +1584,9 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (command[j] === "`") found.carrier = true;
- if (command[j] === "$" && "({[!".includes(command[j + 1] ?? "")) found.carrier = true;
+ if (command[j] === "$" && "({[!123456789@*".includes(command[j + 1] ?? "")) {
+ found.carrier = true;
+ }
j += 1;
}
i = j + 1;
@@ -1603,7 +1607,7 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (char === "$") {
- if ("({['\"!".includes(command[i + 1] ?? "")) found.carrier = true;
+ if ("({['\"!123456789@*".includes(command[i + 1] ?? "")) found.carrier = true;
i += 1;
wordStart = false;
continue;
From 3e02ac59454ddd452c05ab6e5a516e0846dbf3c4 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:02:01 +0000
Subject: [PATCH 031/116] fix: always-set specials localize, oversized commands
skip analysis, preview rejection clears scan state
---
.../Settings/Sections/BackupSection.tsx | 5 +-
src/node/services/backup/payload.test.ts | 62 ++++++++++++++-----
src/node/services/backup/payload.ts | 25 +++++---
tests/ui/BackupSection.test.ts | 26 ++++++++
4 files changed, 93 insertions(+), 25 deletions(-)
diff --git a/src/browser/features/Settings/Sections/BackupSection.tsx b/src/browser/features/Settings/Sections/BackupSection.tsx
index 8b0580256e6..9a11603a80e 100644
--- a/src/browser/features/Settings/Sections/BackupSection.tsx
+++ b/src/browser/features/Settings/Sections/BackupSection.tsx
@@ -359,6 +359,10 @@ 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);
@@ -372,7 +376,6 @@ export function BackupSection() {
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.
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 4dfca348663..ffa7afde21c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -764,23 +764,31 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(`TOKEN=${REDACTED_BACKUP_VALUE} mcp-server`);
});
- it("keeps always-set special parameters from manufacturing a credential block", async () => {
- // Under `bash -c`, $0 is the shell name: the fragments never join at runtime, and
- // the published `$` keeps the run broken for the scan, so creation must succeed.
- await writeFixtureFile(
- muxRoot,
- "mcp.jsonc",
- JSON.stringify({
- servers: { grafana: { command: "mcp --pattern ghp_aaaaaaaaaa$0bbbbbbbbbb" } },
- })
- );
- const payload = await createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- });
- expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa$0bbbbbbbbbb");
+ 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 () => {
@@ -1290,6 +1298,26 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.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("keeps the documented AWS example key reviewable instead of hard-blocking", async () => {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 953b2a1f780..0fc7fa7f3f2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1220,11 +1220,12 @@ const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
/**
* A parameter expansion that can turn into nothing at runtime: an unset variable. No
* plain `$NAME` can gain a value mid-command without an assignment the redaction
- * already rewrites. The specials Bash always fills under `-c` ($0, $?, $#, $$, $-)
- * stay literal instead: their `$` spelling already breaks a token run for the scan,
- * and deleting a value the runtime inserts would manufacture no-override matches from
- * fragments that never join. Positionals and `$@`/`$*`/`$!` are carriers, because the
- * same command string can populate them first (`set -- p`, `&`).
+ * already rewrites, so deletion models its only dangerous runtime faithfully. Every
+ * special parameter is a carrier instead: positionals and `$@`/`$*`/`$!` because the
+ * command string can populate them first (`set -- p`, `&`), and the always-set ones
+ * ($0, $?, $#, $$, $-) because their expansions produce token-charset output
+ * (`ghp_...$#` runs with a trailing `0`) that completes a credential no textual scan
+ * of the spelling reconstructs.
*/
const SIMPLE_EXPANSION = /^\$[A-Za-z_][A-Za-z0-9_]*/;
@@ -1584,7 +1585,7 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (command[j] === "`") found.carrier = true;
- if (command[j] === "$" && "({[!123456789@*".includes(command[j + 1] ?? "")) {
+ if (command[j] === "$" && "({[!0123456789@*#?$-".includes(command[j + 1] ?? "")) {
found.carrier = true;
}
j += 1;
@@ -1607,7 +1608,7 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (char === "$") {
- if ("({['\"!123456789@*".includes(command[i + 1] ?? "")) found.carrier = true;
+ if ("({['\"!0123456789@*#?$-".includes(command[i + 1] ?? "")) found.carrier = true;
i += 1;
wordStart = false;
continue;
@@ -1679,7 +1680,17 @@ function splitCommandComments(command: string): Array<{ code: string; comment: s
return pieces;
}
+/**
+ * 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.
+ */
+const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
+
function redactCommandEnvAssignments(command: string): string {
+ if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
const pieces = splitCommandComments(command);
const redactedPieces = pieces.map((piece) =>
piece.code.replace(
diff --git a/tests/ui/BackupSection.test.ts b/tests/ui/BackupSection.test.ts
index c06ea12c04f..8b52d9366a2 100644
--- a/tests/ui/BackupSection.test.ts
+++ b/tests/ui/BackupSection.test.ts
@@ -435,6 +435,32 @@ describe("BackupSection", () => {
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);
From baed5719376bc27f40833e5900c8dc963f7cafde Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:27:43 +0000
Subject: [PATCH 032/116] fix: localize active named parameter expansions the
command can populate
---
src/node/services/backup/payload.test.ts | 92 ++++++++++++++++--------
src/node/services/backup/payload.ts | 31 ++++----
2 files changed, 78 insertions(+), 45 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ffa7afde21c..4f4ef699d8d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -672,13 +672,11 @@ describe("backup payload", () => {
});
it("keeps inert dollar literals from manufacturing tokens while active ones splice", async () => {
- // Single-quoted and escaped dollars reach the process literally, and even an
- // active `$NAME` swallows the letters behind it into the variable name, so none
- // of these can reassemble a token at runtime.
+ // 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$NAMEbbbbbbbbbb"',
"mcp --pattern 'ghp_aaaaaaaaaa$912345678901234567890'",
"mcp --pattern ghp_aaaaaaaaaa\\$912345678901234567890",
];
@@ -697,13 +695,42 @@ describe("backup payload", () => {
expect(payloadFileText(payload, "mcp.jsonc")).toContain("ghp_aaaaaaaaaa");
}
- // A positional stays active inside double quotes, and the command itself could
- // populate it first, so that spelling goes machine-local instead of publishing.
+ // 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 --pattern "ghp_aaaaaaaaaa$912345678901234567890"' } },
+ servers: {
+ grafana: { command: 'mcp-grafana --token ghp_1234567890$NOPE"12345678901234567890"' },
+ },
})
);
const payload = await createBackupPayload({
@@ -718,30 +745,6 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
});
- it("blocks the export when an empty expansion splices a known credential token", async () => {
- // Bash expands the unset variable to nothing, joining the fragments across the
- // quote boundary that ends its name.
- await writeFixtureFile(
- muxRoot,
- "mcp.jsonc",
- JSON.stringify({
- servers: {
- grafana: { command: 'mcp-grafana --token ghp_1234567890$NOPE"12345678901234567890"' },
- },
- })
- );
- 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("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.
@@ -1298,6 +1301,33 @@ describe("backup payload", () => {
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 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 0fc7fa7f3f2..392ddf6319b 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1218,14 +1218,11 @@ const SHELL_WORD = new RegExp(
const ASSIGNMENT_START = new RegExp(`^${ASSIGNMENT_NAME}`);
/**
- * A parameter expansion that can turn into nothing at runtime: an unset variable. No
- * plain `$NAME` can gain a value mid-command without an assignment the redaction
- * already rewrites, so deletion models its only dangerous runtime faithfully. Every
- * special parameter is a carrier instead: positionals and `$@`/`$*`/`$!` because the
- * command string can populate them first (`set -- p`, `&`), and the always-set ones
- * ($0, $?, $#, $$, $-) because their expansions produce token-charset output
- * (`ghp_...$#` runs with a trailing `0`) that completes a credential no textual scan
- * of the spelling reconstructs.
+ * 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_]*/;
@@ -1548,8 +1545,12 @@ function hasDisguisedAssignment(redacted: string): boolean {
/**
* 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 `$!` depends on
- * execution state, so any of them makes assignment detection undecidable. A
+ * `$'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 passes bytes by file, ambiguous once an
* assignment matched. Single-quoted, escaped, and commented spellings are inert
@@ -1585,7 +1586,7 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (command[j] === "`") found.carrier = true;
- if (command[j] === "$" && "({[!0123456789@*#?$-".includes(command[j + 1] ?? "")) {
+ if (command[j] === "$" && /[({[!0-9@*#?$A-Za-z_-]/.test(command[j + 1] ?? "")) {
found.carrier = true;
}
j += 1;
@@ -1608,7 +1609,7 @@ function findActiveShellConstructs(command: string): {
continue;
}
if (char === "$") {
- if ("({['\"!0123456789@*#?$-".includes(command[i + 1] ?? "")) found.carrier = true;
+ if (/[({['"!0-9@*#?$A-Za-z_-]/.test(command[i + 1] ?? "")) found.carrier = true;
i += 1;
wordStart = false;
continue;
@@ -2059,8 +2060,10 @@ export async function createBackupPayload(
// 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
- // (`ghp_...$9123...`), splicing the fragments around it into one token.
+ // 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
From 2d8390a1710be87ed72f0408c63775dce06d4b12 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 07:35:25 +0000
Subject: [PATCH 033/116] fix: line continuations preserve comment boundaries
in all three command walkers
---
src/node/services/backup/payload.test.ts | 42 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 11 +++++--
2 files changed, 50 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 4f4ef699d8d..39083e571d7 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -745,6 +745,28 @@ describe("backup payload", () => {
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.
@@ -815,6 +837,25 @@ describe("backup payload", () => {
};
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,
@@ -1105,6 +1146,7 @@ describe("backup payload", () => {
for (const command of [
"mcp-server --pattern '$(date)'",
"mcp-server # regenerate with $(date)",
+ "mcp-server \\\n# regenerate with $(date)",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 392ddf6319b..a2b7da6486b 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1338,8 +1338,10 @@ function executedShellWords(text: string): string[] {
// 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].startsWith("#") &&
+ match[0].replace(/^(?:\\\n)+/, "").startsWith("#") &&
(start === 0 ||
before === " " ||
before === "\t" ||
@@ -1568,8 +1570,10 @@ function findActiveShellConstructs(command: string): {
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;
i += 2;
- wordStart = false;
continue;
}
if (char === "'") {
@@ -1658,8 +1662,9 @@ function splitCommandComments(command: string): Array<{ code: string; comment: s
}
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;
- wordStart = false;
continue;
}
if (char === "'" || char === '"') {
From 24ba9e8d1523ae5dcb9dcfb6b72b80db0b0d6251 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:04:51 +0000
Subject: [PATCH 034/116] fix: normalize line continuations before shell
analysis, localize extended globs
---
src/node/services/backup/payload.test.ts | 91 +++++++++++++++++++
src/node/services/backup/payload.ts | 107 +++++++++++++++++++++--
2 files changed, 193 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 39083e571d7..336dab6e8db 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1145,6 +1145,7 @@ describe("backup payload", () => {
// stays portable...
for (const command of [
"mcp-server --pattern '$(date)'",
+ "mcp-server --pattern '@(x|y)'",
"mcp-server # regenerate with $(date)",
"mcp-server \\\n# regenerate with $(date)",
]) {
@@ -1370,6 +1371,96 @@ describe("backup payload", () => {
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 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a2b7da6486b..c8a50112169 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1555,7 +1555,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
* undecidable. A
* here-document or here-string feeds the consumer a body under document rules the word
* scans would misread. Process substitution passes bytes by file, ambiguous once an
- * assignment matched. Single-quoted, escaped, and commented spellings are inert
+ * assignment matched. 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.
*/
@@ -1567,12 +1569,18 @@ function findActiveShellConstructs(command: string): {
const found = { carrier: false, heredoc: false, processSubstitution: 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;
+ if (command[i + 1] !== "\n") {
+ wordStart = false;
+ prevActive = "";
+ }
i += 2;
continue;
}
@@ -1580,6 +1588,7 @@ function findActiveShellConstructs(command: string): {
const end = command.indexOf("'", i + 1);
i = end === -1 ? command.length : end + 1;
wordStart = false;
+ prevActive = "";
continue;
}
if (char === '"') {
@@ -1597,6 +1606,7 @@ function findActiveShellConstructs(command: string): {
}
i = j + 1;
wordStart = false;
+ prevActive = "";
continue;
}
if (char === "#" && wordStart) {
@@ -1604,18 +1614,21 @@ function findActiveShellConstructs(command: string): {
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 === "<") {
@@ -1623,15 +1636,21 @@ function findActiveShellConstructs(command: string): {
if (command[i + 1] === "(") found.processSubstitution = true;
i += 1;
wordStart = true;
+ prevActive = char;
continue;
}
if (char === ">") {
if (command[i + 1] === "(") found.processSubstitution = 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;
@@ -1686,6 +1705,73 @@ function splitCommandComments(command: string): Array<{ code: string; comment: s
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
@@ -1697,7 +1783,10 @@ const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
function redactCommandEnvAssignments(command: string): string {
if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
- const pieces = splitCommandComments(command);
+ // 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,
@@ -1710,7 +1799,7 @@ function redactCommandEnvAssignments(command: string): string {
// 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(command);
+ const constructs = findActiveShellConstructs(analyzed);
if (
UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
hasDisguisedAssignment(redactedCode) ||
@@ -1720,7 +1809,15 @@ function redactCommandEnvAssignments(command: string): string {
) {
return REDACTED_BACKUP_VALUE;
}
- return redactedPieces.map((piece, index) => piece + (pieces[index]?.comment ?? "")).join("");
+ 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;
}
function redactMcpConfig(content: Buffer): {
From 7653f8d3b692da2969be12945dc8962ce62022d8 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:22:37 +0000
Subject: [PATCH 035/116] fix: localize eval reparse and assignment-free
process substitutions
---
src/node/services/backup/payload.test.ts | 45 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 13 ++++---
2 files changed, 54 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 336dab6e8db..032c4a91cef 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1461,6 +1461,51 @@ describe("backup payload", () => {
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("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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c8a50112169..09ba3a7f02e 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1502,6 +1502,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
+ // `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`), wherever the word sits: even mid-command it still names
+ // the builtin to some consumer (`env eval ...`, `bash -c 'eval ...'`).
+ if (unquoted === "eval") return true;
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
@@ -1554,8 +1559,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
* 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 passes bytes by file, ambiguous once an
- * assignment matched. With `extglob` inherited via BASHOPTS, `?( *( +( @( !(` open one
+ * scans would misread. Process substitution hands the consumer a file whose bytes its
+ * inner script chooses (`--token-file <(printf a;printf b)` delivers the joined
+ * credential), assignment or not. 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
@@ -1793,7 +1799,6 @@ function redactCommandEnvAssignments(command: string): string {
(_match, lead: string, name: string) => `${lead}${name}${REDACTED_BACKUP_VALUE}`
)
);
- const code = pieces.map((piece) => piece.code).join("");
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
@@ -1805,7 +1810,7 @@ function redactCommandEnvAssignments(command: string): string {
hasDisguisedAssignment(redactedCode) ||
constructs.carrier ||
constructs.heredoc ||
- (redactedCode !== code && constructs.processSubstitution)
+ constructs.processSubstitution
) {
return REDACTED_BACKUP_VALUE;
}
From c406b6b50f34d884ab606baf040a820b1319a08d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:41:31 +0000
Subject: [PATCH 036/116] fix: block GitLab issued tokens, localize write
redirections
---
src/node/services/backup/payload.test.ts | 68 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 19 +++++--
2 files changed, 82 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 032c4a91cef..473d560c097 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1146,6 +1146,9 @@ describe("backup payload", () => {
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)",
]) {
@@ -1506,6 +1509,71 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(portable);
});
+ it("blocks GitLab tokens in collected files without an override", async () => {
+ // `glpat-` is an issued-only prefix like `ghp_`; a generically named collected
+ // file must not publish one 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.
+ await writeFixtureFile(
+ muxRoot,
+ "skills/demo/SKILL.md",
+ ["token: glpat-", "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 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 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 09ba3a7f02e..a3a680cf197 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -78,6 +78,8 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\bgho_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
/\bglsa_[A-Za-z0-9_]{20,}\b/,
+ // GitLab issued prefixes: personal, deploy, runner, service-account, trigger tokens.
+ /\bgl(?:pat|dt|rt|soat|ptt)-[A-Za-z0-9_-]{20,}\b/,
/\blin_api_[A-Za-z0-9]{16,}\b/,
/\bntn_[A-Za-z0-9]{16,}\b/,
/\bAKIA[0-9A-Z]{16}\b/,
@@ -1559,9 +1561,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
* 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 hands the consumer a file whose bytes its
- * inner script chooses (`--token-file <(printf a;printf b)` delivers the joined
- * credential), assignment or not. With `extglob` inherited via BASHOPTS, `?( *( +( @( !(` open one
+ * 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. 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
@@ -1571,8 +1575,9 @@ function findActiveShellConstructs(command: string): {
carrier: boolean;
heredoc: boolean;
processSubstitution: boolean;
+ redirection: boolean;
} {
- const found = { carrier: false, heredoc: false, processSubstitution: false };
+ const found = { carrier: false, heredoc: false, processSubstitution: false, redirection: false };
let i = 0;
let wordStart = true;
// The previous character as Bash sees it, or "" when that character was quoted or
@@ -1647,6 +1652,9 @@ function findActiveShellConstructs(command: string): {
}
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;
@@ -1810,7 +1818,8 @@ function redactCommandEnvAssignments(command: string): string {
hasDisguisedAssignment(redactedCode) ||
constructs.carrier ||
constructs.heredoc ||
- constructs.processSubstitution
+ constructs.processSubstitution ||
+ constructs.redirection
) {
return REDACTED_BACKUP_VALUE;
}
From 65c613c890849c82085be0c8a9c6b738699ef0fc Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 08:58:38 +0000
Subject: [PATCH 037/116] fix: cover all issued GitHub prefixes, localize
pipelines and shell-state builtins
---
src/node/services/backup/payload.test.ts | 72 +++++++++++++++++++++++-
src/node/services/backup/payload.ts | 59 ++++++++++++++++---
2 files changed, 123 insertions(+), 8 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 473d560c097..92e48d8fdf3 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -357,7 +357,8 @@ describe("backup payload", () => {
// 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`],
- ["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.
[
@@ -1574,6 +1575,75 @@ describe("backup payload", () => {
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",
+ "set -a; printf -v TOKEN %s ghp_aaaaaaaaaabbbbbbbbbb; mcp",
+ ]) {
+ 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);
+ }
+
+ // `||` is a control operator: no bytes flow between its sides.
+ const portable = "mcp-a || mcp-b";
+ 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 every issued GitHub token prefix without an override", async () => {
+ // App user (ghu_), installation (ghs_), and refresh (ghr_) tokens are issued-only
+ // like ghp_/gho_; a collected documentation file must not publish any of them.
+ for (const prefix of ["ghu_", "ghs_", "ghr_"]) {
+ 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("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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a3a680cf197..e1cae97af90 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -74,8 +74,8 @@ function isHiddenName(name: string): boolean {
* defect, and neither is something a backup should publish.
*/
const CREDENTIAL_TOKEN_PATTERNS = [
- /\bghp_[A-Za-z0-9]{20,}\b/,
- /\bgho_[A-Za-z0-9]{20,}\b/,
+ // GitHub issued prefixes: personal, OAuth, App user, installation, refresh tokens.
+ /\bgh[opusr]_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
/\bglsa_[A-Za-z0-9_]{20,}\b/,
// GitLab issued prefixes: personal, deploy, runner, service-account, trigger tokens.
@@ -1487,6 +1487,22 @@ function hasActiveBraceExpansion(active: string): boolean {
return false;
}
+/**
+ * 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",
+ "export",
+ "declare",
+ "typeset",
+ "readonly",
+ "local",
+ "set",
+]);
+
/**
* 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
@@ -1507,8 +1523,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
// `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`), wherever the word sits: even mid-command it still names
- // the builtin to some consumer (`env eval ...`, `bash -c 'eval ...'`).
- if (unquoted === "eval") return true;
+ // the builtin to some consumer (`env eval ...`, `bash -c 'eval ...'`). 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.
+ if (SHELL_STATE_WORDS.has(unquoted)) return true;
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
@@ -1565,7 +1584,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
* 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. With `extglob` inherited via BASHOPTS, `?( *( +( @( !(` open one
+ * 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
@@ -1576,8 +1597,15 @@ function findActiveShellConstructs(command: string): {
heredoc: boolean;
processSubstitution: boolean;
redirection: boolean;
+ pipeline: boolean;
} {
- const found = { carrier: false, heredoc: false, processSubstitution: false, redirection: false };
+ 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
@@ -1660,6 +1688,22 @@ function findActiveShellConstructs(command: string): {
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;
}
@@ -1819,7 +1863,8 @@ function redactCommandEnvAssignments(command: string): string {
constructs.carrier ||
constructs.heredoc ||
constructs.processSubstitution ||
- constructs.redirection
+ constructs.redirection ||
+ constructs.pipeline
) {
return REDACTED_BACKUP_VALUE;
}
From c76f875ca7dce42d0e309bd667d420f557c0e6e0 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:15:02 +0000
Subject: [PATCH 038/116] fix: cover remaining GitLab issued prefixes, localize
shopt
---
src/node/services/backup/payload.test.ts | 42 ++++++++++++++----------
src/node/services/backup/payload.ts | 8 +++--
2 files changed, 30 insertions(+), 20 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 92e48d8fdf3..66234dea9f1 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1511,25 +1511,29 @@ describe("backup payload", () => {
});
it("blocks GitLab tokens in collected files without an override", async () => {
- // `glpat-` is an issued-only prefix like `ghp_`; a generically named collected
- // file must not publish one 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.
- await writeFixtureFile(
- muxRoot,
- "skills/demo/SKILL.md",
- ["token: glpat-", "K3vQ9rT2wY7bN4mJ6hL8", "\n"].join("")
- );
- const blocked = await captureRejection(
- createBackupPayload({
+ // 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,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- })
- );
- expect(blocked).toBeInstanceOf(BackupCredentialDetectedError);
- expect((blocked as BackupCredentialDetectedError).files).toEqual(["skills/demo/SKILL.md"]);
+ "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 commands that write files through active redirection", async () => {
@@ -1585,6 +1589,8 @@ describe("backup payload", () => {
"printf ghp_aaaaaaaaaa | mcp-server",
"printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; export TOKEN; mcp",
"set -a; printf -v TOKEN %s ghp_aaaaaaaaaabbbbbbbbbb; mcp",
+ // `shopt -so allexport` flips the same allexport state `set -a` does.
+ "shopt -so allexport; printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e1cae97af90..d04d7447ab0 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -78,8 +78,9 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\bgh[opusr]_[A-Za-z0-9]{20,}\b/,
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
/\bglsa_[A-Za-z0-9_]{20,}\b/,
- // GitLab issued prefixes: personal, deploy, runner, service-account, trigger tokens.
- /\bgl(?:pat|dt|rt|soat|ptt)-[A-Za-z0-9_-]{20,}\b/,
+ // GitLab issued prefixes: personal, deploy, runner, service-account, trigger,
+ // CI job, OAuth app, feature-flag, incoming-mail, and cluster-agent tokens.
+ /\bgl(?:pat|dt|rt|soat|ptt|cbt|oas|ffct|imt|agent)-[A-Za-z0-9_-]{20,}\b/,
/\blin_api_[A-Za-z0-9]{16,}\b/,
/\bntn_[A-Za-z0-9]{16,}\b/,
/\bAKIA[0-9A-Z]{16}\b/,
@@ -1501,6 +1502,9 @@ const SHELL_STATE_WORDS = new Set([
"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",
]);
/**
From fde5856bb6b74621fb2298834f0fdd9d04ed42cf Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:20:13 +0000
Subject: [PATCH 039/116] fix: letter glob classes localize under inherited
nocaseglob
---
src/node/services/backup/payload.test.ts | 25 +++++++++++++++++++++--
src/node/services/backup/payload.ts | 26 ++++++++++++++++--------
2 files changed, 40 insertions(+), 11 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 66234dea9f1..5d7d9ffbde5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -918,13 +918,13 @@ describe("backup payload", () => {
});
it("collapses deterministic globs so a bracketed spelling cannot hide a token", async () => {
- // `[b]` matches only `b`: pathname expansion can hand the process the contiguous
+ // `[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[b]aaaaaaaaa" } },
+ servers: { grafana: { command: "mcp --pattern ghp_aaaaaaaaaa[8]aaaaaaaaa" } },
})
);
const blocked = await captureRejection(
@@ -937,6 +937,27 @@ describe("backup payload", () => {
);
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,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d04d7447ab0..a8b46751e2b 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1306,14 +1306,18 @@ function unquoteShellWord(word: string, stripExpansions = false, collapseGlobs =
continue;
}
if (collapseGlobs) {
- // Pathname expansion is live in this unquoted context. A single-member class is
- // deterministic (`[8]` can only produce `8`), and any reader collapses the
- // published spelling the same way, so scan what it yields. Nondeterministic
- // wildcards never reach this scan: redaction localizes their whole command.
- if (char === "[" && word[i + 2] === "]" && !"!^]\\'\"".includes(word[i + 1] ?? "")) {
- result += word[i + 1];
- i += 3;
- continue;
+ // 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;
@@ -1450,7 +1454,11 @@ function hasNondeterministicGlob(word: string): boolean {
}
if (char === "?" || char === "*") return true;
if (char === "[") {
- if (word[i + 2] === "]" && !"!^]\\'\"".includes(word[i + 1] ?? "")) {
+ // 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;
}
From 870fb7fd0704e9f540bc6f7c9ab0b06eefe297e1 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 09:38:20 +0000
Subject: [PATCH 040/116] fix: localize source builtins, block xapp tokens,
bound aggregate analysis
---
src/node/services/backup/payload.test.ts | 37 +++++++++++++++++++++---
src/node/services/backup/payload.ts | 29 +++++++++++++++++--
2 files changed, 59 insertions(+), 7 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 5d7d9ffbde5..3a846a9643d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -14,6 +14,7 @@ import {
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,
@@ -1612,6 +1613,9 @@ describe("backup payload", () => {
"set -a; printf -v TOKEN %s ghp_aaaaaaaaaabbbbbbbbbb; mcp",
// `shopt -so allexport` flips the same allexport state `set -a` does.
"shopt -so allexport; printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
+ // `source` and `.` run a file in this shell with fragments as positionals.
+ "source ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
+ ". ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
]) {
await writeFixtureFile(
muxRoot,
@@ -1649,10 +1653,11 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(portable);
});
- it("blocks every issued GitHub token prefix without an override", async () => {
- // App user (ghu_), installation (ghs_), and refresh (ghr_) tokens are issued-only
- // like ghp_/gho_; a collected documentation file must not publish any of them.
- for (const prefix of ["ghu_", "ghs_", "ghr_"]) {
+ it("blocks every issued GitHub and Slack token prefix without an override", async () => {
+ // App user (ghu_), installation (ghs_), and refresh (ghr_) GitHub tokens and
+ // Slack app-level (xapp-) tokens 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-"]) {
await writeFixtureFile(
muxRoot,
"skills/demo/SKILL.md",
@@ -1671,6 +1676,30 @@ describe("backup payload", () => {
}
});
+ 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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index a8b46751e2b..22f5ac4bdc2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -84,7 +84,8 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\blin_api_[A-Za-z0-9]{16,}\b/,
/\bntn_[A-Za-z0-9]{16,}\b/,
/\bAKIA[0-9A-Z]{16}\b/,
- /\bxox[baprs]-[A-Za-z0-9-]{10,}\b/,
+ // Slack workspace (xox?-) and app-level (xapp-) issued tokens.
+ /\bx(?:ox[baprs]|app)-[A-Za-z0-9-]{10,}\b/,
] as const;
/**
@@ -1513,6 +1514,12 @@ const SHELL_STATE_WORDS = new Set([
// `shopt -so allexport` flips the same allexport state `set -a` does, and
// `shopt -s expand_aliases` opens alias rewriting of later lines.
"shopt",
+ // `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). A bare `.`
+ // argument (the cwd) localizes with it: keywords like `do` make command-position
+ // detection undecidable here, so the dot fails closed like every ambiguous form.
+ "source",
+ ".",
]);
/**
@@ -1849,7 +1856,15 @@ function removeActiveLineContinuations(command: string): string {
* balloon memory. No legitimate portable command approaches this length; beyond it the
* command goes machine-local without being parsed at all.
*/
-const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
+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): string {
if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
@@ -1905,8 +1920,16 @@ function redactMcpConfig(content: Buffer): {
redactionPaths.push([...jsonPath]);
}
+ let analysisBudget = MAX_TOTAL_ANALYZED_COMMAND_LENGTH;
+
function redactCommand(jsonPath: jsonc.JSONPath, command: string): void {
- const redacted = redactCommandEnvAssignments(command);
+ let redacted: string;
+ if (command.length > analysisBudget) {
+ redacted = REDACTED_BACKUP_VALUE;
+ } else {
+ analysisBudget -= command.length;
+ redacted = redactCommandEnvAssignments(command);
+ }
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
redactionPaths.push([...jsonPath]);
From 8863eaea6856873a775c0c18bd6a18e84ef593a6 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 10:11:17 +0000
Subject: [PATCH 041/116] fix: named shell interpreters localize their reparsed
script arguments
---
src/node/services/backup/payload.test.ts | 4 ++++
src/node/services/backup/payload.ts | 26 ++++++++++++++++++++++++
2 files changed, 30 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 3a846a9643d..1ee715a8cd6 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1616,6 +1616,10 @@ describe("backup payload", () => {
// `source` and `.` run a file in this shell with fragments as positionals.
"source ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
". ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
+ // A named shell reparses its -c payload, where ${IFS} synthesizes the
+ // whitespace the interpreter-string rule keys on and \K unescapes.
+ "bash -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "/bin/sh -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 22f5ac4bdc2..41f1ebdd2d3 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1497,6 +1497,31 @@ function hasActiveBraceExpansion(active: string): boolean {
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, covering `/bin/sh` spellings. 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",
+ "pwsh",
+ "powershell",
+]);
+
/**
* 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
@@ -1547,6 +1572,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// no `=` or `$` in the text (`printf -v TOKEN ...; export TOKEN`), and `set`
// reaches the same end through `-a` or the positional parameters.
if (SHELL_STATE_WORDS.has(unquoted)) return true;
+ if (SHELL_INTERPRETER_NAMES.has(unquoted.slice(unquoted.lastIndexOf("/") + 1))) return true;
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
From b96a3bbe14c9aa628bac6c3f9607555ffb910b12 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 10:29:51 +0000
Subject: [PATCH 042/116] fix: normalize Windows interpreter spellings before
the shell-name check
---
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 10 ++++++++--
2 files changed, 11 insertions(+), 2 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 1ee715a8cd6..30afe7a0178 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1620,6 +1620,9 @@ describe("backup payload", () => {
// whitespace the interpreter-string rule keys on and \K unescapes.
"bash -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
"/bin/sh -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
+ // Windows spellings: .exe suffix, backslash paths, case-insensitive names.
+ "bash.exe -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "'C:\\Tools\\PWSH.EXE' -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 41f1ebdd2d3..8917176574b 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1501,7 +1501,10 @@ function hasActiveBraceExpansion(active: string): boolean {
* 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, covering `/bin/sh` spellings. A
+ * 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.
@@ -1572,7 +1575,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
// no `=` or `$` in the text (`printf -v TOKEN ...; export TOKEN`), and `set`
// reaches the same end through `-a` or the positional parameters.
if (SHELL_STATE_WORDS.has(unquoted)) return true;
- if (SHELL_INTERPRETER_NAMES.has(unquoted.slice(unquoted.lastIndexOf("/") + 1))) return true;
+ const executable = unquoted
+ .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
+ .toLowerCase();
+ if (SHELL_INTERPRETER_NAMES.has(executable.replace(/\.exe$/, ""))) return true;
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
From 275e6404d59c7a043d8b4fc3385678ace71e5c5c Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 10:42:49 +0000
Subject: [PATCH 043/116] fix: block Stripe live keys without an override
---
src/node/services/backup/payload.test.ts | 11 ++++++-----
src/node/services/backup/payload.ts | 3 +++
2 files changed, 9 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 30afe7a0178..cd7ed973dbc 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1660,11 +1660,12 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(portable);
});
- it("blocks every issued GitHub and Slack token prefix without an override", async () => {
- // App user (ghu_), installation (ghs_), and refresh (ghr_) GitHub tokens and
- // Slack app-level (xapp-) tokens 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-"]) {
+ it("blocks every issued GitHub, Slack, and Stripe token prefix without an override", async () => {
+ // 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_"]) {
await writeFixtureFile(
muxRoot,
"skills/demo/SKILL.md",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 8917176574b..e53d344c5c5 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -86,6 +86,9 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\bAKIA[0-9A-Z]{16}\b/,
// Slack workspace (xox?-) and app-level (xapp-) issued tokens.
/\bx(?:ox[baprs]|app)-[A-Za-z0-9-]{10,}\b/,
+ // Stripe live secret and restricted keys. Test-mode keys stay reviewable:
+ // documentation routinely quotes them, and the block has no override.
+ /\b[sr]k_live_[A-Za-z0-9]{16,}\b/,
] as const;
/**
From e3b897f98eda02e432382a9e41bd247f53aa5e39 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:00:16 +0000
Subject: [PATCH 044/116] fix: localize language-interpreter eval spellings,
block npm tokens
---
src/node/services/backup/payload.test.ts | 30 ++++++++++++++++++++++-
src/node/services/backup/payload.ts | 31 ++++++++++++++++++++++--
2 files changed, 58 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index cd7ed973dbc..792bfeb1017 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1623,6 +1623,11 @@ describe("backup payload", () => {
// Windows spellings: .exe suffix, backslash paths, case-insensitive names.
"bash.exe -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
"'C:\\Tools\\PWSH.EXE' -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
+ // Language interpreters concatenate fragments under their own grammar when a
+ // script-evaluation option is present.
+ 'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
+ "node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
+ 'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
]) {
await writeFixtureFile(
muxRoot,
@@ -1641,6 +1646,29 @@ describe("backup payload", () => {
expect(mcp.servers.grafana.command).toBe(REDACTED_BACKUP_VALUE);
}
+ // Without a script-evaluation option the interpreter runs a file: the everyday
+ // portable MCP launchers must keep publishing.
+ for (const command of [
+ "node ./server.js --transport stdio",
+ "python3 -m mcp_server --port 8080",
+ ]) {
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({ servers: { grafana: { command } } })
+ );
+ const filePayload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ const fileMcp = jsonc.parse(payloadFileText(filePayload, "mcp.jsonc")) as {
+ servers: { grafana: { command: string } };
+ };
+ expect(fileMcp.servers.grafana.command).toBe(command);
+ }
+
// `||` is a control operator: no bytes flow between its sides.
const portable = "mcp-a || mcp-b";
await writeFixtureFile(
@@ -1665,7 +1693,7 @@ describe("backup payload", () => {
// 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_"]) {
+ for (const prefix of ["ghu_", "ghs_", "ghr_", "xapp-1-", "sk_live_", "rk_live_", "npm_"]) {
await writeFixtureFile(
muxRoot,
"skills/demo/SKILL.md",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e53d344c5c5..3524be687d4 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -89,6 +89,8 @@ const CREDENTIAL_TOKEN_PATTERNS = [
// Stripe live secret and restricted keys. Test-mode keys stay reviewable:
// documentation routinely quotes them, and the block has no override.
/\b[sr]k_live_[A-Za-z0-9]{16,}\b/,
+ // npm issued access tokens.
+ /\bnpm_[A-Za-z0-9]{24,}\b/,
] as const;
/**
@@ -1528,6 +1530,25 @@ const SHELL_INTERPRETER_NAMES = new Set([
"powershell",
]);
+/**
+ * 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`); which letters evaluate is per-interpreter knowledge
+ * this table owns, unlike arbitrary programs' options.
+ */
+const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
+ { name: /^python[0-9.]*$/, evalWord: /^-[A-Za-z]*c/ },
+ { name: /^(?:node|nodejs)$/, evalWord: /^(?:--eval|--print|-[A-Za-z]*[ep])/ },
+ { name: /^bun$/, evalWord: /^(?:--eval|--print|-[A-Za-z]*[ep])/ },
+ { name: /^deno$/, evalWord: /^eval$/ },
+ { name: /^perl[0-9.]*$/, evalWord: /^-[A-Za-z]*[eE]/ },
+ { name: /^ruby[0-9.]*$/, evalWord: /^-[A-Za-z]*e/ },
+ { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z]*[rR]/ },
+];
+
/**
* 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
@@ -1561,6 +1582,7 @@ const SHELL_STATE_WORDS = new Set([
*/
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
+ const pendingEvalWords: RegExp[] = [];
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
// Bash expands neither syntax from quoted or escaped text (`--config
@@ -1580,8 +1602,13 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (SHELL_STATE_WORDS.has(unquoted)) return true;
const executable = unquoted
.slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
- .toLowerCase();
- if (SHELL_INTERPRETER_NAMES.has(executable.replace(/\.exe$/, ""))) return true;
+ .toLowerCase()
+ .replace(/\.exe$/, "");
+ if (SHELL_INTERPRETER_NAMES.has(executable)) return true;
+ // An evaluation word after a language interpreter hands that grammar a script.
+ if (pendingEvalWords.some((pattern) => pattern.test(unquoted))) return true;
+ const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
+ if (language) pendingEvalWords.push(language.evalWord);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
From 68b568d248e0110468e469f10465b73523d698ba Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:17:31 +0000
Subject: [PATCH 045/116] fix: localize deferred traps and printf variable
writes under inherited allexport
---
src/node/services/backup/payload.test.ts | 6 ++++++
src/node/services/backup/payload.ts | 8 ++++++++
2 files changed, 14 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 792bfeb1017..8167534614f 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1610,7 +1610,13 @@ describe("backup payload", () => {
"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.
+ "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",
// `shopt -so allexport` flips the same allexport state `set -a` does.
"shopt -so allexport; printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
// `source` and `.` run a file in this shell with fragments as positionals.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3524be687d4..2b614ef95a2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1557,6 +1557,9 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
*/
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",
"export",
"declare",
"typeset",
@@ -1582,6 +1585,7 @@ const SHELL_STATE_WORDS = new Set([
*/
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
+ let pendingPrintfVariableOption = false;
const pendingEvalWords: RegExp[] = [];
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
@@ -1592,6 +1596,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
+ // 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 === "-v") return true;
+ pendingPrintfVariableOption = 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`), wherever the word sits: even mid-command it still names
From b309d0f341c7c7c8345429d65a79d2032c6d5b8a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:38:21 +0000
Subject: [PATCH 046/116] fix: block temporary AWS keys and localize awk
program operands
---
src/node/services/backup/payload.test.ts | 20 ++++++++++++++++++++
src/node/services/backup/payload.ts | 12 +++++++++++-
2 files changed, 31 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 8167534614f..374cfff44c3 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1631,6 +1631,9 @@ describe("backup payload", () => {
"'C:\\Tools\\PWSH.EXE' -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
// Language interpreters concatenate fragments under their own grammar when a
// script-evaluation option is present.
+ // awk-family executables evaluate their first program operand without an
+ // explicit eval option.
+ 'awk \'BEGIN{system("mcp"sprintf("%c",32)"--token"sprintf("%c",32)"ghp_Abcdef1234""Klmno56789")}\'',
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
@@ -1762,6 +1765,23 @@ describe("backup payload", () => {
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,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 2b614ef95a2..c0c6eaa5338 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -83,7 +83,8 @@ const CREDENTIAL_TOKEN_PATTERNS = [
/\bgl(?:pat|dt|rt|soat|ptt|cbt|oas|ffct|imt|agent)-[A-Za-z0-9_-]{20,}\b/,
/\blin_api_[A-Za-z0-9]{16,}\b/,
/\bntn_[A-Za-z0-9]{16,}\b/,
- /\bAKIA[0-9A-Z]{16}\b/,
+ // AWS long-term (AKIA) and temporary-session (ASIA) access-key IDs.
+ /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
// Slack workspace (xox?-) and app-level (xapp-) issued tokens.
/\bx(?:ox[baprs]|app)-[A-Za-z0-9-]{10,}\b/,
// Stripe live secret and restricted keys. Test-mode keys stay reviewable:
@@ -1530,6 +1531,14 @@ const SHELL_INTERPRETER_NAMES = new Set([
"powershell",
]);
+/**
+ * awk-family executables evaluate a program operand by default, with no `-c`/`-e`
+ * marker to distinguish it from a file launcher. Localize the invocation as soon as
+ * its exact normalized executable name appears; the portability cost of `awk -f` is
+ * preferable to parsing each awk implementation's option grammar and failing open.
+ */
+const PROGRAM_OPERAND_INTERPRETER_NAMES = new Set(["awk", "gawk", "mawk", "nawk", "goawk"]);
+
/**
* Language interpreters whose script-evaluation spellings reparse an operand under the
* language's own grammar, where quoted fragments concatenate into one runtime value
@@ -1613,6 +1622,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
.toLowerCase()
.replace(/\.exe$/, "");
if (SHELL_INTERPRETER_NAMES.has(executable)) return true;
+ if (PROGRAM_OPERAND_INTERPRETER_NAMES.has(executable)) return true;
// An evaluation word after a language interpreter hands that grammar a script.
if (pendingEvalWords.some((pattern) => pattern.test(unquoted))) return true;
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
From 88416fc38d0b1c46a1823d2b7b8282c2a6f3c893 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 11:42:53 +0000
Subject: [PATCH 047/116] fix: recognize attached printf variable output
options
---
src/node/services/backup/payload.test.ts | 4 +++-
src/node/services/backup/payload.ts | 2 +-
2 files changed, 4 insertions(+), 2 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 374cfff44c3..4e787c0ab92 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1611,7 +1611,9 @@ describe("backup payload", () => {
"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.
+ // 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
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c0c6eaa5338..dc11ac83993 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1607,7 +1607,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
const unquoted = unquoteShellWord(word);
// 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 === "-v") return true;
+ if (pendingPrintfVariableOption && unquoted.startsWith("-v")) return true;
pendingPrintfVariableOption = 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
From e5cc9b309c7c58c235f360239b0c418e4260f02f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 12:19:57 +0000
Subject: [PATCH 048/116] fix: localize MCP commands when Bash startup hooks
are inherited
Non-interactive bash sources a non-empty BASH_ENV file and imports
BASH_FUNC_* exported functions before parsing its -c command, so a hook
can redefine any command word and rewrite the semantics every command
analyzer models. While the exporting process's environment (or a
server's own config env, which merges over it at spawn) carries such a
hook, commands go machine-local without being analyzed.
---
src/node/services/backup/payload.test.ts | 90 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 38 +++++++++-
2 files changed, 125 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 4e787c0ab92..4a4c25f340c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -136,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;
@@ -1558,6 +1575,79 @@ describe("backup payload", () => {
}
});
+ 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("localizes a server's command when its config env installs startup hooks", async () => {
+ // server.env merges over the inherited environment at spawn, so one entry can hook
+ // its own shell on a clean machine; only that server's command goes local.
+ await writeFixtureFile(
+ muxRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ hooked: { command: "mcp-server --port 8080", env: { BASH_ENV: "./startup.sh" } },
+ clean: { command: "other-server --flag value" },
+ },
+ })
+ );
+ 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 }; clean: { command: string } };
+ };
+ expect(mcp.servers.hooked.command).toBe(REDACTED_BACKUP_VALUE);
+ expect(mcp.servers.clean.command).toBe("other-server --flag 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
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index dc11ac83993..471b20d139d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1986,6 +1986,21 @@ function redactCommandEnvAssignments(command: string): string {
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.
+ */
+function isBashStartupHookVariable(name: string, value: unknown): boolean {
+ if (name.startsWith("BASH_FUNC_")) return true;
+ return name === "BASH_ENV" && value !== "" && value !== undefined;
+}
+
function redactMcpConfig(content: Buffer): {
content: Buffer;
redactionPaths: BackupRedactionPath[];
@@ -2001,10 +2016,17 @@ function redactMcpConfig(content: Buffer): {
}
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 {
+ function redactCommand(
+ jsonPath: jsonc.JSONPath,
+ command: string,
+ serverStartupHooks = false
+ ): void {
let redacted: string;
- if (command.length > analysisBudget) {
+ if (ambientStartupHooks || serverStartupHooks || command.length > analysisBudget) {
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
@@ -2061,6 +2083,15 @@ function redactMcpConfig(content: Buffer): {
continue;
}
+ // Config env merges over the inherited environment at spawn, so a server entry can
+ // hook its own shell even when this process's environment is clean.
+ const serverEnv = readRecord(readOwn(server, "env"));
+ const serverStartupHooks =
+ serverEnv !== undefined &&
+ objectKeyNames(tree, ["servers", serverName, "env"]).some((name) =>
+ isBashStartupHookVariable(name, readOwn(serverEnv, name))
+ );
+
for (const field of objectKeyNames(tree, ["servers", serverName])) {
const fieldPath: jsonc.JSONPath = ["servers", serverName, field];
const value = readOwn(server, field);
@@ -2074,7 +2105,8 @@ function redactMcpConfig(content: Buffer): {
redact(fieldPath);
continue;
}
- if (field === "command" && typeof value === "string") redactCommand(fieldPath, value);
+ if (field === "command" && typeof value === "string")
+ redactCommand(fieldPath, value, serverStartupHooks);
// 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.
From 60410d722546e80b9c77d00797681a9be68c40e6 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:28:41 +0000
Subject: [PATCH 049/116] fix: localize sed program operands; publish commands
despite ignored config env
GNU sed's e command executes script text from the first non-option
operand, so sed joins the awk family in the program-operand interpreter
set. Config-level server env is dropped by McpConfigService's
normalizeEntry before spawn and never reaches runtime.exec, so a
startup hook there no longer localizes an otherwise portable command;
doing so only made a fresh-device restore drop the whole server. Only
the exporting process's inherited environment gates commands.
---
src/node/services/backup/payload.test.ts | 21 +++++++----
src/node/services/backup/payload.ts | 48 ++++++++++++------------
2 files changed, 37 insertions(+), 32 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 4a4c25f340c..dea4f4ff363 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1622,16 +1622,18 @@ describe("backup payload", () => {
}
});
- it("localizes a server's command when its config env installs startup hooks", async () => {
- // server.env merges over the inherited environment at spawn, so one entry can hook
- // its own shell on a clean machine; only that server's command goes local.
+ 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: "mcp-server --port 8080", env: { BASH_ENV: "./startup.sh" } },
- clean: { command: "other-server --flag value" },
+ hooked: { command: portable, env: { BASH_ENV: "./startup.sh" } },
},
})
);
@@ -1642,10 +1644,10 @@ describe("backup payload", () => {
reportSecrets: true,
});
const mcp = jsonc.parse(payloadFileText(payload, "mcp.jsonc")) as {
- servers: { hooked: { command: string }; clean: { command: string } };
+ servers: { hooked: { command: string; env: string } };
};
- expect(mcp.servers.hooked.command).toBe(REDACTED_BACKUP_VALUE);
- expect(mcp.servers.clean.command).toBe("other-server --flag value");
+ expect(mcp.servers.hooked.command).toBe(portable);
+ expect(mcp.servers.hooked.env).toBe(REDACTED_BACKUP_VALUE);
});
it("localizes commands that write files through active redirection", async () => {
@@ -1726,6 +1728,9 @@ describe("backup payload", () => {
// awk-family executables evaluate their first program operand without an
// explicit eval option.
'awk \'BEGIN{system("mcp"sprintf("%c",32)"--token"sprintf("%c",32)"ghp_Abcdef1234""Klmno56789")}\'',
+ // GNU sed evaluates its first non-option operand as a program whose `e`
+ // command hands the script text to a shell.
+ "sed '1eexec${IFS}mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' /etc/hostname",
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 471b20d139d..6c7125e90c0 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1532,12 +1532,22 @@ const SHELL_INTERPRETER_NAMES = new Set([
]);
/**
- * awk-family executables evaluate a program operand by default, with no `-c`/`-e`
- * marker to distinguish it from a file launcher. Localize the invocation as soon as
- * its exact normalized executable name appears; the portability cost of `awk -f` is
- * preferable to parsing each awk implementation's option grammar and failing open.
- */
-const PROGRAM_OPERAND_INTERPRETER_NAMES = new Set(["awk", "gawk", "mawk", "nawk", "goawk"]);
+ * 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",
+]);
/**
* Language interpreters whose script-evaluation spellings reparse an operand under the
@@ -1994,7 +2004,11 @@ function redactCommandEnvAssignments(command: string): string {
* 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.
+ * 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).
*/
function isBashStartupHookVariable(name: string, value: unknown): boolean {
if (name.startsWith("BASH_FUNC_")) return true;
@@ -2020,13 +2034,9 @@ function redactMcpConfig(content: Buffer): {
isBashStartupHookVariable(name, value)
);
- function redactCommand(
- jsonPath: jsonc.JSONPath,
- command: string,
- serverStartupHooks = false
- ): void {
+ function redactCommand(jsonPath: jsonc.JSONPath, command: string): void {
let redacted: string;
- if (ambientStartupHooks || serverStartupHooks || command.length > analysisBudget) {
+ if (ambientStartupHooks || command.length > analysisBudget) {
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
@@ -2083,15 +2093,6 @@ function redactMcpConfig(content: Buffer): {
continue;
}
- // Config env merges over the inherited environment at spawn, so a server entry can
- // hook its own shell even when this process's environment is clean.
- const serverEnv = readRecord(readOwn(server, "env"));
- const serverStartupHooks =
- serverEnv !== undefined &&
- objectKeyNames(tree, ["servers", serverName, "env"]).some((name) =>
- isBashStartupHookVariable(name, readOwn(serverEnv, name))
- );
-
for (const field of objectKeyNames(tree, ["servers", serverName])) {
const fieldPath: jsonc.JSONPath = ["servers", serverName, field];
const value = readOwn(server, field);
@@ -2105,8 +2106,7 @@ function redactMcpConfig(content: Buffer): {
redact(fieldPath);
continue;
}
- if (field === "command" && typeof value === "string")
- redactCommand(fieldPath, value, serverStartupHooks);
+ 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.
From 34e4741a2d1e9da21e75ca53002091996ffabf23 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 13:48:24 +0000
Subject: [PATCH 050/116] fix: localize mapfile callbacks and remote-reparse
executables
mapfile/readarray evaluate their -C callback text as a command, like
trap actions, so they join SHELL_STATE_WORDS. OpenSSH sends command
operands to the remote login shell for a second parse pass this scan
never sees, and scp/rsync remote paths expand through the same remote
shell, so ssh, slogin, autossh, scp, and rsync localize on sight.
---
src/node/services/backup/payload.test.ts | 8 ++++++++
src/node/services/backup/payload.ts | 15 +++++++++++++++
2 files changed, 23 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index dea4f4ff363..41fd0fe818a 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1711,6 +1711,9 @@ describe("backup payload", () => {
// 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 {
// GNU sed evaluates its first non-option operand as a program whose `e`
// command hands the script text to a shell.
"sed '1eexec${IFS}mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' /etc/hostname",
+ // OpenSSH hands command operands to the remote login shell for a second parse
+ // pass that removes the surviving backslash; scp/rsync remote paths
+ // historically expand through the same remote shell.
+ "ssh mcp-host mcp --token ghp_Abcdef1234\\\\Klmno56789",
+ "scp 'mcp-host:ghp_Abcdef1234\\Klmno56789' /tmp/dest",
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 6c7125e90c0..e7163a1fe17 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1549,6 +1549,16 @@ const PROGRAM_OPERAND_INTERPRETER_NAMES = new Set([
"gsed",
]);
+/**
+ * Executables whose operands are handed to another shell parse this scan never sees:
+ * 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. Naming one
+ * localizes the invocation, accepting the portability cost like the program-operand
+ * interpreters above.
+ */
+const REMOTE_REPARSE_EXECUTABLE_NAMES = new Set(["ssh", "slogin", "autossh", "scp", "rsync"]);
+
/**
* Language interpreters whose script-evaluation spellings reparse an operand under the
* language's own grammar, where quoted fragments concatenate into one runtime value
@@ -1579,6 +1589,10 @@ const SHELL_STATE_WORDS = new Set([
// 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",
"export",
"declare",
"typeset",
@@ -1633,6 +1647,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
.replace(/\.exe$/, "");
if (SHELL_INTERPRETER_NAMES.has(executable)) return true;
if (PROGRAM_OPERAND_INTERPRETER_NAMES.has(executable)) return true;
+ if (REMOTE_REPARSE_EXECUTABLE_NAMES.has(executable)) return true;
// An evaluation word after a language interpreter hands that grammar a script.
if (pendingEvalWords.some((pattern) => pattern.test(unquoted))) return true;
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
From bd7c1b4e1fb7a461204f9e9468d4e40603c9cc1d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:11:08 +0000
Subject: [PATCH 051/116] fix: recognize PHP -B/-E code options and local
shell-reparse wrappers
PHP executes -B/-E begin/end code operands like -r/-R run code, so the
eval-word pattern covers all four letters. su, runuser, and sudo hand
their command operand to the target user's shell, and watch, flock,
script, and tmux run theirs through an sh -c-style pass, so the remote
reparse set generalizes to SHELL_REPARSE_EXECUTABLE_NAMES covering the
local wrapper family in one sweep.
---
src/node/services/backup/payload.test.ts | 10 +++++++++
src/node/services/backup/payload.ts | 28 +++++++++++++++++++-----
2 files changed, 32 insertions(+), 6 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 41fd0fe818a..39ffebed2a5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1739,9 +1739,19 @@ describe("backup payload", () => {
// historically expand through the same remote shell.
"ssh mcp-host mcp --token ghp_Abcdef1234\\\\Klmno56789",
"scp 'mcp-host:ghp_Abcdef1234\\Klmno56789' /tmp/dest",
+ // su/runuser/sudo hand their command operand to the target user's shell, and
+ // watch runs its command through `sh -c`; each adds a parse pass that expands
+ // ${IFS} and removes the backslash the first parse kept.
+ "su target -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "runuser target -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "sudo -s 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
+ "watch 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
+ // PHP executes -r/-R run code and -B/-E begin/end code operands alike.
+ 'php -B \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
+ 'php8.3 -E \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e7163a1fe17..25cf661ae30 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1550,14 +1550,29 @@ const PROGRAM_OPERAND_INTERPRETER_NAMES = new Set([
]);
/**
- * Executables whose operands are handed to another shell parse this scan never sees:
- * OpenSSH sends command words to the remote login shell for re-evaluation
+ * 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. Naming one
+ * 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 REMOTE_REPARSE_EXECUTABLE_NAMES = new Set(["ssh", "slogin", "autossh", "scp", "rsync"]);
+const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
+ "ssh",
+ "slogin",
+ "autossh",
+ "scp",
+ "rsync",
+ "su",
+ "runuser",
+ "sudo",
+ "watch",
+ "flock",
+ "script",
+ "tmux",
+]);
/**
* Language interpreters whose script-evaluation spellings reparse an operand under the
@@ -1575,7 +1590,8 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
{ name: /^deno$/, evalWord: /^eval$/ },
{ name: /^perl[0-9.]*$/, evalWord: /^-[A-Za-z]*[eE]/ },
{ name: /^ruby[0-9.]*$/, evalWord: /^-[A-Za-z]*e/ },
- { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z]*[rR]/ },
+ // -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
+ { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z]*[rRBE]/ },
];
/**
@@ -1647,7 +1663,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
.replace(/\.exe$/, "");
if (SHELL_INTERPRETER_NAMES.has(executable)) return true;
if (PROGRAM_OPERAND_INTERPRETER_NAMES.has(executable)) return true;
- if (REMOTE_REPARSE_EXECUTABLE_NAMES.has(executable)) return true;
+ if (SHELL_REPARSE_EXECUTABLE_NAMES.has(executable)) return true;
// An evaluation word after a language interpreter hands that grammar a script.
if (pendingEvalWords.some((pattern) => pattern.test(unquoted))) return true;
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
From 917f4334805f63c9b2560090c7677c37a908e670 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 14:42:09 +0000
Subject: [PATCH 052/116] fix: bound interpreter lookup, recognize
apiToken-style URL credentials
pendingEvalWords becomes a Set of the static table's RegExp instances,
so repeated interpreter words cannot grow it past the table size and
the lookup stays linear (a pathological 256 KiB config dropped from
1.4s to 0.13s). The credential URL parameter names gain apitoken and
its alias class (apisecret, apptoken, appkey, authkey, clienttoken,
privatetoken, secrettoken, securitytoken, sessiontoken). Off-host
startup hooks (container images, remote SSH hosts) are documented as
out of export's observable scope; neutralizing those shells belongs to
the runtime spawn paths.
---
src/common/config/schemas/settingsBackup.ts | 10 ++++++++++
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 16 ++++++++++++----
3 files changed, 25 insertions(+), 4 deletions(-)
diff --git a/src/common/config/schemas/settingsBackup.ts b/src/common/config/schemas/settingsBackup.ts
index 9f3cadff42c..7d203b2d032 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,12 +106,16 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"passwd",
"password",
"privatekey",
+ "privatetoken",
"pwd",
"refreshtoken",
"secret",
"secretaccesskey",
"secretkey",
+ "secrettoken",
+ "securitytoken",
"sessionid",
+ "sessiontoken",
"signature",
"token",
"xamzcredential",
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 39ffebed2a5..fe0f780658c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -4240,6 +4240,9 @@ 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?private_token=hunter2",
+ "https://mcp.example.com/mcp?sessionToken=hunter2",
"https://mcp.example.com/mcp?code=review",
"https://mcp.example.com/mcp?X-Amz-Signature=deadbeef",
"https://mcp.example.com/callback?code=oauth-code",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 25cf661ae30..5cf84cc10b1 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1635,7 +1635,9 @@ const SHELL_STATE_WORDS = new Set([
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
let pendingPrintfVariableOption = false;
- const pendingEvalWords: RegExp[] = [];
+ // A Set of the static table's RegExp instances: repeated interpreter words cannot
+ // grow it past the table size, keeping this lookup linear in command length.
+ const pendingEvalWords = new Set();
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
// Bash expands neither syntax from quoted or escaped text (`--config
@@ -1665,9 +1667,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (PROGRAM_OPERAND_INTERPRETER_NAMES.has(executable)) return true;
if (SHELL_REPARSE_EXECUTABLE_NAMES.has(executable)) return true;
// An evaluation word after a language interpreter hands that grammar a script.
- if (pendingEvalWords.some((pattern) => pattern.test(unquoted))) return true;
+ for (const pattern of pendingEvalWords) {
+ if (pattern.test(unquoted)) return true;
+ }
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
- if (language) pendingEvalWords.push(language.evalWord);
+ if (language) pendingEvalWords.add(language.evalWord);
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
@@ -2039,7 +2043,11 @@ function redactCommandEnvAssignments(command: string): string {
* 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).
+ * 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;
From 1cd0b8417d4b4fa311a96e0b271911652502ffff Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:11:24 +0000
Subject: [PATCH 053/116] fix: reject over-limit MCP redaction queues before
applying edits
Each queued edit costs a full-document jsonc.modify pass in finish(),
so a config that queues tens of thousands of redactions bought
edit-count x document-size synchronous main-process work before the
256-redaction cap rejected it (54s measured on 6000 short commands).
The count assertion now runs as edits queue, rejecting the same
payloads with the same error in milliseconds.
---
src/node/services/backup/payload.ts | 5 +++++
1 file changed, 5 insertions(+)
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 5cf84cc10b1..f8e69e402eb 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2066,6 +2066,10 @@ 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;
@@ -2084,6 +2088,7 @@ function redactMcpConfig(content: Buffer): {
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
redactionPaths.push([...jsonPath]);
+ assertBackupMcpRedactionCount(redactionPaths.length);
}
function finish(): { content: Buffer; redactionPaths: BackupRedactionPath[] } {
From 60337c206c64582fab9c1825f414195eb1d668a2 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:32:04 +0000
Subject: [PATCH 054/116] fix: match x-prefixed URL credential names, end eval
tracking at script operands
Header-style query parameters prefix the same credential names with x
(x-api-key, X-Auth-Token); one stripped leading x matches the whole
class against the existing name set. Interpreter eval-option tracking
now clears at the first non-option word no pending pattern matched:
that word is the script or module operand, and later dash-led words
belong to that program, so file launchers like python3 server.py -c
settings.toml stay portable instead of localizing and being dropped on
a fresh-device restore.
---
src/common/config/schemas/settingsBackup.ts | 6 +++++-
src/node/services/backup/payload.test.ts | 8 ++++++++
src/node/services/backup/payload.ts | 10 +++++++++-
3 files changed, 22 insertions(+), 2 deletions(-)
diff --git a/src/common/config/schemas/settingsBackup.ts b/src/common/config/schemas/settingsBackup.ts
index 7d203b2d032..5153b6c988c 100644
--- a/src/common/config/schemas/settingsBackup.ts
+++ b/src/common/config/schemas/settingsBackup.ts
@@ -128,7 +128,11 @@ function parametersContainCredential(
): 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;
}
return false;
}
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index fe0f780658c..294c31b7412 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1747,6 +1747,8 @@ describe("backup payload", () => {
"sudo -s 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
"watch 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
+ // Interpreter options before the eval option keep tracking armed.
+ "python3 -u -c 'x'",
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
// PHP executes -r/-R run code and -B/-E begin/end code operands alike.
@@ -1775,6 +1777,10 @@ describe("backup payload", () => {
for (const command of [
"node ./server.js --transport stdio",
"python3 -m mcp_server --port 8080",
+ // Dash-led words after the script/module operand belong to that program, not
+ // the interpreter: server.py receives -c, Rails receives -e.
+ "python3 server.py -c settings.toml",
+ "ruby app.rb -e production",
]) {
await writeFixtureFile(
muxRoot,
@@ -4241,6 +4247,8 @@ describe("backup payload", () => {
"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?code=review",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index f8e69e402eb..fba0306170a 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1671,7 +1671,15 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (pattern.test(unquoted)) return true;
}
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
- if (language) pendingEvalWords.add(language.evalWord);
+ if (language) {
+ pendingEvalWords.add(language.evalWord);
+ } else if (pendingEvalWords.size > 0 && !unquoted.startsWith("-")) {
+ // A non-option word no pending pattern matched is the script/module operand:
+ // later dash-led words 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.
+ pendingEvalWords.clear();
+ }
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
// GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
From 253bb52d1378bef982645f9853204bef5d37ee1b Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 15:52:24 +0000
Subject: [PATCH 055/116] fix: batch jsonc edit application, keep eval tracking
armed past option operands
applyJsoncEdits plans one text span per replacement and one merged span
per deletion run against a single parse, splicing in one pass; batches
the planner cannot place exactly fall back to the sequential path. A
crafted backup with 256 valid URL deletions on a near-limit document
cost nearly a minute of synchronous jsonc.modify reparses before
(19.6s measured on an accepted 3.7 MB export); the batched pass takes
0.17s. Eval-option tracking now stays armed once a dash-led interpreter
option appears, because its separate argument is indistinguishable from
a script operand (python3 -W ignore -c evaluates); a clean leading
non-option word still ends tracking so file launchers stay portable.
---
src/node/services/backup/payload.test.ts | 4 +-
src/node/services/backup/payload.ts | 150 +++++++++++++++++++++--
2 files changed, 141 insertions(+), 13 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 294c31b7412..c686fba69a6 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1747,8 +1747,10 @@ describe("backup payload", () => {
"sudo -s 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
"watch 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
'python3 -c \'__import__("os").environ.update({"T":"ghp_Abcdef1234"+"Klmno56789"})\'',
- // Interpreter options before the eval option keep tracking armed.
+ // Interpreter options before the eval option keep tracking armed, including
+ // options whose separate argument looks like a script operand.
"python3 -u -c 'x'",
+ 'python3 -W ignore -c \'__import__("os").system("mcp"+chr(32)+"--token"+chr(32)+"ghp_Abcdef1234"+"Klmno56789")\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
// PHP executes -r/-R run code and -B/-E begin/end code operands alike.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index fba0306170a..73d74de6538 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1016,16 +1016,133 @@ 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 }>) {
- let result = text;
+ 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;
+}
+
+/**
+ * Plans one text span per replacement and one merged span per deletion run, all against
+ * a single parse. Returns undefined for any batch it cannot place exactly (a missing
+ * node, a segment/container type mismatch, 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) {
- result = jsonc.applyEdits(
- result,
- jsonc.modify(result, edit.path, edit.value, JSONC_EDIT_OPTIONS)
- );
+ 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);
+ }
+ if (deleted.size === children.length) {
+ // Empty the container: everything between its delimiters goes.
+ spans.push({ offset: parent.offset + 1, length: parent.length - 2, content: "" });
+ continue;
+ }
+ for (let start = 0; start < children.length; start += 1) {
+ if (!deleted.has(start)) continue;
+ let end = start;
+ while (end + 1 < children.length && deleted.has(end + 1)) end += 1;
+ const first = children[start];
+ const last = children[end];
+ const follower = children[end + 1];
+ if (first === undefined || last === undefined) return undefined;
+ if (follower !== undefined) {
+ // A kept entry follows: the run's span ends where it begins, taking the
+ // run's separators with it and leaving the follower's own separator intact.
+ spans.push({ offset: first.offset, length: follower.offset - first.offset, content: "" });
+ } else {
+ // The run reaches the container's end, so a kept entry precedes it (the
+ // all-deleted case returned above): start after that entry, taking the
+ // separator between them.
+ const previous = children[start - 1];
+ if (previous === undefined) return undefined;
+ const previousEnd = previous.offset + previous.length;
+ spans.push({
+ offset: previousEnd,
+ length: last.offset + last.length - previousEnd,
+ content: "",
+ });
+ }
+ start = end;
+ }
}
- return result;
+ 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 {
@@ -1635,6 +1752,7 @@ const SHELL_STATE_WORDS = new Set([
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
let pendingPrintfVariableOption = false;
+ let evalOperandAmbiguous = false;
// A Set of the static table's RegExp instances: repeated interpreter words cannot
// grow it past the table size, keeping this lookup linear in command length.
const pendingEvalWords = new Set();
@@ -1673,12 +1791,20 @@ function hasDisguisedAssignment(redacted: string): boolean {
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
if (language) {
pendingEvalWords.add(language.evalWord);
- } else if (pendingEvalWords.size > 0 && !unquoted.startsWith("-")) {
- // A non-option word no pending pattern matched is the script/module operand:
- // later dash-led words 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.
- pendingEvalWords.clear();
+ evalOperandAmbiguous = false;
+ } else if (pendingEvalWords.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 (!evalOperandAmbiguous) {
+ // The first non-option word no pending pattern matched is the script/module
+ // operand: later dash-led words 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.
+ pendingEvalWords.clear();
+ }
}
// Option terminators end option parsing: past one even a dash-led word is an
// operand, so `env -- --evil=x` sets an environment entry despite the option look.
From b1daf9b264cd13d6dc700e062be3da044d77f581 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:14:53 +0000
Subject: [PATCH 056/116] fix: linear placeholder-run stripping, model read
under inherited allexport
V8 exhausts its call stack evaluating /(.)\1{15,}/gi across a
multi-megabyte single-character run (reproduced on Node 22; Bun's JSC
tolerates it), rejecting a size-valid file before scanning. The
replacement is one linear pass with identical semantics, including
case-insensitive joining and line terminators never forming runs.
read joins SHELL_STATE_WORDS: it builds a variable from input bytes
with backslash joining unless -r, and inherited allexport exports what
it builds, so read TOKEN {
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.
+ await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", "x".repeat(4 * 1024 * 1024));
+ const payload = await createBackupPayload({
+ muxRoot,
+ muxVersion: "1.2.3",
+ sourceLabel: "test-host",
+ reportSecrets: true,
+ });
+ expect(payloadFileText(payload, "skills/demo/SKILL.md").length).toBe(4 * 1024 * 1024);
+ });
+
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
@@ -1714,6 +1727,9 @@ describe("backup payload", () => {
// `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 = 16) {
+ result += text.slice(keptFrom, i) + " ";
+ keptFrom = end;
+ }
+ i = end;
+ }
+ return result + text.slice(keptFrom);
+}
function matchesCredentialToken(text: string): boolean {
- const scannable = text.replaceAll(EXAMPLE_ACCESS_KEY, " ").replace(PLACEHOLDER_RUN, " ");
+ const scannable = stripPlaceholderRuns(text.replaceAll(EXAMPLE_ACCESS_KEY, " "));
return (
CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(scannable)) ||
hasDigitBearingSkToken(scannable)
@@ -1726,6 +1747,9 @@ const SHELL_STATE_WORDS = new Set([
// 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",
From a8391cf4637b558ef7423c60bc28af90faac0e3d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:25:07 +0000
Subject: [PATCH 057/116] fix: localize cmd.exe command operands
Executable normalization maps cmd.exe to cmd, but the reparsing shell
set omitted it: cmd's /c parse consumes carets Git Bash preserves,
joining published fragments into one runtime token.
---
src/node/services/backup/payload.test.ts | 2 ++
src/node/services/backup/payload.ts | 2 ++
2 files changed, 4 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 050a41c1a60..01e60d9597c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1742,6 +1742,8 @@ describe("backup payload", () => {
// Windows spellings: .exe suffix, backslash paths, case-insensitive names.
"bash.exe -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
"'C:\\Tools\\PWSH.EXE' -c 'printf${IFS}%s${IFS}ghp_Abcdef1234\\Klmno56789'",
+ // cmd.exe's /c parse consumes carets Git Bash preserves, joining fragments.
+ "cmd.exe //d //c mcp --token ghp_Abcdefghij12345678^KlmnoPqrst98765432",
// Language interpreters concatenate fragments under their own grammar when a
// script-evaluation option is present.
// awk-family executables evaluate their first program operand without an
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index f499abe7b4b..3570084ea20 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1665,6 +1665,8 @@ const SHELL_INTERPRETER_NAMES = new Set([
"tcsh",
"fish",
"busybox",
+ // cmd.exe reparses its /c operand, consuming carets that split fragments upstream.
+ "cmd",
"pwsh",
"powershell",
]);
From 05531684e9b597be01a6eb09bf0085e7fad7f391 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:35:26 +0000
Subject: [PATCH 058/116] fix: preserve gap comments when batched deletion
removes a final property
Deletion spans now take exactly the child node plus its separator
comma, located by a comment-aware gap scan, instead of merging runs
from the preceding property's end; a comment attached to a retained
neighbor survives, and a dangling JSONC trailing comma after a deleted
final entry is removed. Gaps holding anything unrecognizable send the
batch to the sequential fallback.
---
src/node/services/backup/payload.test.ts | 53 +++++++++++++
src/node/services/backup/payload.ts | 97 +++++++++++++++---------
2 files changed, 116 insertions(+), 34 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 01e60d9597c..98179a5a429 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2886,6 +2886,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,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3570084ea20..d64d0bb7ac1 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1073,10 +1073,37 @@ interface JsoncEditSpan {
}
/**
- * Plans one text span per replacement and one merged span per deletion run, all against
- * a single parse. Returns undefined for any batch it cannot place exactly (a missing
- * node, a segment/container type mismatch, overlapping spans), handing those to the
- * sequential path instead of guessing.
+ * 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,
@@ -1123,37 +1150,39 @@ function planJsoncEditSpans(
if (index < 0 || index >= children.length || deleted.has(index)) return undefined;
deleted.add(index);
}
- if (deleted.size === children.length) {
- // Empty the container: everything between its delimiters goes.
- spans.push({ offset: parent.offset + 1, length: parent.length - 2, content: "" });
- continue;
- }
- for (let start = 0; start < children.length; start += 1) {
- if (!deleted.has(start)) continue;
- let end = start;
- while (end + 1 < children.length && deleted.has(end + 1)) end += 1;
- const first = children[start];
- const last = children[end];
- const follower = children[end + 1];
- if (first === undefined || last === undefined) return undefined;
- if (follower !== undefined) {
- // A kept entry follows: the run's span ends where it begins, taking the
- // run's separators with it and leaving the follower's own separator intact.
- spans.push({ offset: first.offset, length: follower.offset - first.offset, content: "" });
- } else {
- // The run reaches the container's end, so a kept entry precedes it (the
- // all-deleted case returned above): start after that entry, taking the
- // separator between them.
- const previous = children[start - 1];
- if (previous === undefined) return undefined;
- const previousEnd = previous.offset + previous.length;
- spans.push({
- offset: previousEnd,
- length: last.offset + last.length - previousEnd,
- content: "",
- });
+ // 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: "" });
}
- start = end;
}
}
spans.sort((a, b) => a.offset - b.offset);
From fef5a8bcad9af975be95dfa04ccbeebf99d3c669 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 16:56:03 +0000
Subject: [PATCH 059/116] fix: cluster numeric interpreter switches before eval
letters
Perl and Ruby accept the numeric -0[octal] switch clustered before -e
(perl -0e 'exec(...)'), but the eval-word patterns admitted only letters,
so the eval spelling passed as a portable file launch and published the
credential-bearing script. Admit digits in every cluster class; invalid
numeric clusters for the other interpreters merely fail closed.
---
src/node/services/backup/payload.test.ts | 4 ++++
src/node/services/backup/payload.ts | 17 +++++++++--------
2 files changed, 13 insertions(+), 8 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 98179a5a429..5c2a838b670 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1771,6 +1771,10 @@ describe("backup payload", () => {
'python3 -W ignore -c \'__import__("os").system("mcp"+chr(32)+"--token"+chr(32)+"ghp_Abcdef1234"+"Klmno56789")\'',
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
+ // Perl and Ruby cluster the numeric `-0[octal]` switch before the eval
+ // letter, so digits count as cluster characters alongside letters.
+ 'perl -0e \'exec("mcp","--token","ghp_Abcdef1234"."Klmno56789")\'',
+ 'ruby -0e \'exec("mcp","--token","ghp_Abcdef1234"+"Klmno56789")\'',
// PHP executes -r/-R run code and -B/-E begin/end code operands alike.
'php -B \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
'php8.3 -E \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d64d0bb7ac1..c345c4f05d5 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1749,18 +1749,19 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
* (`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`); which letters evaluate is per-interpreter knowledge
- * this table owns, unlike arbitrary programs' options.
+ * 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.
*/
const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
- { name: /^python[0-9.]*$/, evalWord: /^-[A-Za-z]*c/ },
- { name: /^(?:node|nodejs)$/, evalWord: /^(?:--eval|--print|-[A-Za-z]*[ep])/ },
- { name: /^bun$/, evalWord: /^(?:--eval|--print|-[A-Za-z]*[ep])/ },
+ { name: /^python[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*c/ },
+ { name: /^(?:node|nodejs)$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
+ { name: /^bun$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
{ name: /^deno$/, evalWord: /^eval$/ },
- { name: /^perl[0-9.]*$/, evalWord: /^-[A-Za-z]*[eE]/ },
- { name: /^ruby[0-9.]*$/, evalWord: /^-[A-Za-z]*e/ },
+ { name: /^perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
+ { name: /^ruby[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
- { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z]*[rRBE]/ },
+ { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[rRBE]/ },
];
/**
From e39924f4574e259314b3199b324c3c6dc9fbd00f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:25:34 +0000
Subject: [PATCH 060/116] fix: address round 52 review findings
- Skip the duplicate NUL-stripped scan pass for NUL-free content and fold
case per UTF-16 unit arithmetically in stripPlaceholderRuns instead of
allocating per-character lowercase strings: the 64 MiB worst-case backstop
scan drops from ~4.4s to ~1s on V8 (parity-checked against the old fold
across 2000 fuzz cases).
- Recognize the Windows py/pyw launcher and windowed pythonw/rubyw/wperl/
php-win builds as language interpreters so their eval spellings localize.
- Localize history/fc: with inherited SHELLOPTS=history, history -s stores
arguments as one entry and fc -s reparses them, expanding what the first
parse kept quoted.
- Match provider-prefixed signed-URL credential parameters (X-Goog-Signature,
X-Goog-Credential, x-oss-security-token) by unambiguous name suffix instead
of relying on one stripped leading x.
---
src/common/config/schemas/settingsBackup.ts | 14 ++++++
src/node/services/backup/payload.test.ts | 17 ++++++++
src/node/services/backup/payload.ts | 47 ++++++++++++++++-----
3 files changed, 67 insertions(+), 11 deletions(-)
diff --git a/src/common/config/schemas/settingsBackup.ts b/src/common/config/schemas/settingsBackup.ts
index 5153b6c988c..0d4d27c30d9 100644
--- a/src/common/config/schemas/settingsBackup.ts
+++ b/src/common/config/schemas/settingsBackup.ts
@@ -122,6 +122,19 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"xamzsignature",
]);
+/**
+ * Signed-URL families qualify the credential word with a provider prefix
+ * (`X-Goog-Signature`, `X-Amz-Credential`, `x-oss-security-token`), so these
+ * unambiguous words match as name suffixes rather than enumerating providers.
+ */
+const CREDENTIAL_NAME_SUFFIXES = [
+ "accesskeyid",
+ "credential",
+ "secretaccesskey",
+ "securitytoken",
+ "signature",
+] as const;
+
function parametersContainCredential(
parameters: URLSearchParams,
names: ReadonlySet
@@ -133,6 +146,7 @@ function parametersContainCredential(
// 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 (CREDENTIAL_NAME_SUFFIXES.some((suffix) => normalizedName.endsWith(suffix))) return true;
}
return false;
}
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 5c2a838b670..c5e3095e49d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1769,12 +1769,25 @@ describe("backup payload", () => {
// options whose separate argument looks like a script operand.
"python3 -u -c 'x'",
'python3 -W ignore -c \'__import__("os").system("mcp"+chr(32)+"--token"+chr(32)+"ghp_Abcdef1234"+"Klmno56789")\'',
+ // The Windows launchers and windowed variants run the same evaluation grammars
+ // under different executable names.
+ 'py.exe -c \'__import__("os").system("mcp"+chr(32)+"--token"+chr(32)+"ghp_Abcdef1234"+"Klmno56789")\'',
+ "pyw -c 'x'",
+ "pythonw -c 'x'",
+ "rubyw -e 'x'",
+ "wperl -e 'x'",
+ "php-win.exe -r 'x'",
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
// Perl and Ruby cluster the numeric `-0[octal]` switch before the eval
// letter, so digits count as cluster characters alongside letters.
'perl -0e \'exec("mcp","--token","ghp_Abcdef1234"."Klmno56789")\'',
'ruby -0e \'exec("mcp","--token","ghp_Abcdef1234"+"Klmno56789")\'',
+ // `history -s` stores its arguments as one entry and `fc -s` reparses the
+ // stored command with inherited SHELLOPTS=history, expanding what the first
+ // parse kept quoted.
+ "history -s 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'; fc -s",
+ "fc -s",
// PHP executes -r/-R run code and -B/-E begin/end code operands alike.
'php -B \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
'php8.3 -E \'system("mcp".chr(32)."--token".chr(32)."ghp_Abcdef1234"."Klmno56789");\'',
@@ -4330,6 +4343,10 @@ describe("backup payload", () => {
"https://mcp.example.com/mcp?sessionToken=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",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c345c4f05d5..bc8652da98f 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -116,6 +116,21 @@ function hasDigitBearingSkToken(text: string): boolean {
*/
const EXAMPLE_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE";
+/**
+ * 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 string folding (which
+ * also catches cross-plane pairs 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 String.fromCharCode(a).toLowerCase() === String.fromCharCode(b).toLowerCase();
+}
+
/**
* A run of one repeated character (case-insensitive) is documentation spelling, never
* issued-token entropy (`ghp_xxxxxxxx...`), so those spellings stay in the reviewable
@@ -131,11 +146,10 @@ function stripPlaceholderRuns(text: string): string {
let keptFrom = 0;
let i = 0;
while (i < text.length) {
- const anchor = text[i] ?? "";
+ const anchor = text.charCodeAt(i);
let end = i + 1;
- if (!"\n\r\u2028\u2029".includes(anchor)) {
- const folded = anchor.toLowerCase();
- while (end < text.length && text[end]?.toLowerCase() === folded) end += 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) + " ";
@@ -143,7 +157,7 @@ function stripPlaceholderRuns(text: string): string {
}
i = end;
}
- return result + text.slice(keptFrom);
+ return keptFrom === 0 ? text : result + text.slice(keptFrom);
}
function matchesCredentialToken(text: string): boolean {
@@ -1754,14 +1768,17 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
* is per-interpreter knowledge this table owns, unlike arbitrary programs' options.
*/
const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
- { name: /^python[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*c/ },
+ // 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.
+ { name: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[A-Za-z0-9]*c/ },
{ name: /^(?:node|nodejs)$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
{ name: /^bun$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
{ name: /^deno$/, evalWord: /^eval$/ },
- { name: /^perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
- { name: /^ruby[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
+ { name: /^w?perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
+ { name: /^rubyw?[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
- { name: /^php[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[rRBE]/ },
+ { name: /^(?:php[0-9.]*|php-win)$/, evalWord: /^-[A-Za-z0-9]*[rRBE]/ },
];
/**
@@ -1791,6 +1808,11 @@ const SHELL_STATE_WORDS = new Set([
// `shopt -so allexport` flips the same allexport state `set -a` does, and
// `shopt -s expand_aliases` opens alias rewriting of later lines.
"shopt",
+ // 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). A bare `.`
// argument (the cwd) localizes with it: keywords like `do` make command-position
@@ -2585,8 +2607,11 @@ export async function createBackupPayload(
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.
- const targets = [content, content.replaceAll("\u0000", ""), file.path];
+ // 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", ""));
// 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.
From a68adb4d9296b9354fbec456e8dc97967b60225a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 17:54:33 +0000
Subject: [PATCH 061/116] fix: harden backup module scanning and size checks
---
src/node/services/backup/payload.test.ts | 30 +++++++++++++-----------
src/node/services/backup/payload.ts | 21 ++++++++++++++---
2 files changed, 34 insertions(+), 17 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index c5e3095e49d..8a376a5f40d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1778,6 +1778,11 @@ describe("backup payload", () => {
"wperl -e 'x'",
"php-win.exe -r 'x'",
"node -e \"require('child_process').spawnSync('mcp',['--token','ghp_Abcdef1234'+'Klmno56789'])\"",
+ // Preload options and executable data URLs evaluate inline module code; the URL
+ // rule also covers runners such as deno whose subcommand ends option tracking.
+ `node --import 'data:text/javascript,import{spawnSync}from"node:child_process";spawnSync("mcp",["--token","ghp_Abcdef1234"+"Klmno56789"])' server.js`,
+ `node --loader='data:text/javascript,import{spawnSync}from"node:child_process";spawnSync("mcp",["--token","ghp_Abcdef1234"+"Klmno56789"])' server.js`,
+ `deno run 'data:text/javascript,new Deno.Command("mcp",{args:["--token","ghp_Abcdef1234"+"Klmno56789"]}).outputSync()'`,
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
// Perl and Ruby cluster the numeric `-0[octal]` switch before the eval
// letter, so digits count as cluster characters alongside letters.
@@ -2621,22 +2626,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");
});
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index bc8652da98f..3edd60ddc14 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -490,6 +490,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
@@ -1772,8 +1776,14 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
// windowed `pythonw`/`rubyw`/`wperl`/`php-win` builds run the same evaluation
// grammars under different executable names.
{ name: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[A-Za-z0-9]*c/ },
- { name: /^(?:node|nodejs)$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
- { name: /^bun$/, evalWord: /^(?:--eval|--print|-[A-Za-z0-9]*[ep])/ },
+ {
+ name: /^(?:node|nodejs)$/,
+ evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[A-Za-z0-9]*[ep])/,
+ },
+ {
+ name: /^bun$/,
+ evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[A-Za-z0-9]*[ep])/,
+ },
{ name: /^deno$/, evalWord: /^eval$/ },
{ name: /^w?perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
{ name: /^rubyw?[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
@@ -1855,6 +1865,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
// no `=` or `$` in the text (`printf -v TOKEN ...; export TOKEN`), and `set`
// reaches the same end through `-a` or the positional parameters.
if (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;
const executable = unquoted
.slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
.toLowerCase()
@@ -2589,6 +2602,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.
@@ -2737,7 +2752,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(
From 7a85209a6f1f080fb4c20d5799b0621270f0e8f4 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 18:00:12 +0000
Subject: [PATCH 062/116] tests: tighten executable data URL coverage
---
src/node/services/backup/payload.test.ts | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 8a376a5f40d..92a11b3f1b5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1781,8 +1781,8 @@ describe("backup payload", () => {
// Preload options and executable data URLs evaluate inline module code; the URL
// rule also covers runners such as deno whose subcommand ends option tracking.
`node --import 'data:text/javascript,import{spawnSync}from"node:child_process";spawnSync("mcp",["--token","ghp_Abcdef1234"+"Klmno56789"])' server.js`,
- `node --loader='data:text/javascript,import{spawnSync}from"node:child_process";spawnSync("mcp",["--token","ghp_Abcdef1234"+"Klmno56789"])' server.js`,
- `deno run 'data:text/javascript,new Deno.Command("mcp",{args:["--token","ghp_Abcdef1234"+"Klmno56789"]}).outputSync()'`,
+ `node --loader=data:text/javascript,import%7BspawnSync%7Dfrom%22node%3Achild_process%22%3BspawnSync%28%22mcp%22%2C%5B%22--token%22%2C%22ghp_Abcdef1234%22%2B%22Klmno56789%22%5D%29 server.js`,
+ `deno run 'data:text/javascript,new(Deno.Command)("mcp",{args:["--token","ghp_Abcdef1234"+"Klmno56789"]}).outputSync()'`,
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
// Perl and Ruby cluster the numeric `-0[octal]` switch before the eval
// letter, so digits count as cluster characters alongside letters.
From e2bb5eb11a29983d5a77486b9fe14c30c9a0341a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 18:18:42 +0000
Subject: [PATCH 063/116] fix: address round 54 review findings
- Cache case folds per UTF-16 unit as fold-class ids in a lazily filled
typed array, so adversarial runs alternating case-equivalent units
(U+212A KELVIN SIGN with k) compare by integer id instead of allocating
two lowercase strings per comparison: the 64Mi-unit worst case drops
1763ms to 427ms on Node (parity-checked, 0 mismatches across 300k+
sampled and fuzzed unit pairs).
- Localize xargs and GNU parallel: xargs' default input parsing removes
backslashes and quotes from stdin or an -a argument file, reconstructing
a token a collected file carries split, and parallel runs its composed
command lines through a shell.
---
src/node/services/backup/payload.test.ts | 27 ++++++++++++------
src/node/services/backup/payload.ts | 35 ++++++++++++++++++++++--
2 files changed, 50 insertions(+), 12 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 92a11b3f1b5..e22da2ff5de 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1652,15 +1652,20 @@ describe("backup payload", () => {
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.
- await writeFixtureFile(muxRoot, "skills/demo/SKILL.md", "x".repeat(4 * 1024 * 1024));
- const payload = await createBackupPayload({
- muxRoot,
- muxVersion: "1.2.3",
- sourceLabel: "test-host",
- reportSecrets: true,
- });
- expect(payloadFileText(payload, "skills/demo/SKILL.md").length).toBe(4 * 1024 * 1024);
+ // 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 cross-plane 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);
+ }
});
it("localizes commands that write files through active redirection", async () => {
@@ -1760,6 +1765,10 @@ describe("backup payload", () => {
// su/runuser/sudo hand their command operand to the target user's shell, and
// watch runs its command through `sh -c`; each adds a parse pass that expands
// ${IFS} and removes the backslash the first parse kept.
+ // xargs' default input parsing removes backslashes and quotes from the argument
+ // file, reconstructing a split token; GNU parallel runs its command via a shell.
+ "xargs -a /home/user/.xum/AGENTS.md mcp --token",
+ "parallel 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789' ::: run",
"su target -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
"runuser target -c 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
"sudo -s 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3edd60ddc14..60d4c4e82d8 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -116,11 +116,35 @@ function hasDigitBearingSkToken(text: string): boolean {
*/
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 string folding (which
- * also catches cross-plane pairs like U+212A KELVIN SIGN and `k`).
+ * ASCII folds arithmetically; only a non-ASCII unit pays for a cached fold-class
+ * lookup (which also catches cross-plane pairs like U+212A KELVIN SIGN and `k`).
*/
function sameFoldedUnit(a: number, b: number): boolean {
if (a === b) return true;
@@ -128,7 +152,7 @@ function sameFoldedUnit(a: number, b: number): boolean {
const foldedB = b >= 65 && b <= 90 ? b + 32 : b;
if (foldedA === foldedB) return true;
if (a < 128 && b < 128) return false;
- return String.fromCharCode(a).toLowerCase() === String.fromCharCode(b).toLowerCase();
+ return foldClassId(a) === foldClassId(b);
}
/**
@@ -1759,6 +1783,11 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
"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",
]);
/**
From ebbec9889f8e3b1027bb2f9bdb56cd202d676838 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 19:45:45 +0000
Subject: [PATCH 064/116] fix: require a provider marker for credential-suffix
URL parameters
The generic suffix rule classified descriptive options such as
verify_signature=false as credentials, rejecting legitimate backup
repository URLs and redacting whole MCP URLs that a fresh-device restore
then drops. Signed-URL families always carry a header-style x-led provider
prefix (X-Goog-Signature, X-Amz-Credential, x-oss-security-token), so the
suffix now matches only x-led names while bare credential words stay
covered by the exact-name set and the one-x-strip rule.
---
.../config/schemas/appConfigOnDisk.test.ts | 3 +++
src/common/config/schemas/settingsBackup.ts | 19 ++++++++-----------
src/node/services/backup/payload.test.ts | 9 ++++++++-
3 files changed, 19 insertions(+), 12 deletions(-)
diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts
index ca63302fe93..3f8e107a4ca 100644
--- a/src/common/config/schemas/appConfigOnDisk.test.ts
+++ b/src/common/config/schemas/appConfigOnDisk.test.ts
@@ -149,6 +149,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 0d4d27c30d9..58e705cb116 100644
--- a/src/common/config/schemas/settingsBackup.ts
+++ b/src/common/config/schemas/settingsBackup.ts
@@ -123,17 +123,14 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
]);
/**
- * Signed-URL families qualify the credential word with a provider prefix
- * (`X-Goog-Signature`, `X-Amz-Credential`, `x-oss-security-token`), so these
- * unambiguous words match as name suffixes rather than enumerating providers.
+ * 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 CREDENTIAL_NAME_SUFFIXES = [
- "accesskeyid",
- "credential",
- "secretaccesskey",
- "securitytoken",
- "signature",
-] as const;
+const PROVIDER_CREDENTIAL_NAME =
+ /^x[a-z0-9]*(?:accesskeyid|credential|secretaccesskey|securitytoken|signature)$/;
function parametersContainCredential(
parameters: URLSearchParams,
@@ -146,7 +143,7 @@ function parametersContainCredential(
// 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 (CREDENTIAL_NAME_SUFFIXES.some((suffix) => normalizedName.endsWith(suffix))) return true;
+ if (PROVIDER_CREDENTIAL_NAME.test(normalizedName)) return true;
}
return false;
}
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index e22da2ff5de..f3d42e9b566 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -4431,7 +4431,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" },
@@ -4446,6 +4446,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 () => {
From 9f6ef339f98bd9ec114c7a52c3a3832b35687468 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:11:35 +0000
Subject: [PATCH 065/116] fix: bind restore command approval to the exact
planned MCP bytes
Approval and planning resolved the local mcp.jsonc separately, so a
concurrent edit between the reads could diverge them: the approval-time
resolution rehydrates a redacted url (shadowing the backup's command and
exempting it), while the plan-time resolution sees the url gone, drops the
marker, and writes the repository-controlled command runnable without the
user ever reading it. Restore now plans first and computes approvals from
the exact mcp.jsonc bytes the plan writes.
---
src/node/services/backup/payload.test.ts | 72 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 29 +++++++---
2 files changed, 93 insertions(+), 8 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index f3d42e9b566..a0bcb5d9364 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -3960,6 +3960,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,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 60d4c4e82d8..0aad5608a14 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -3240,6 +3240,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 =
@@ -3887,18 +3899,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);
From 376bf7c76780a83b2b1e76174f807568261d6bbe Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:38:11 +0000
Subject: [PATCH 066/116] fix: localize additional downstream evaluation modes
- Track npx -c/--call and npm exec/x -c/--call without localizing
ordinary package launchers.
- Recognize R and Rscript -e/--expression evaluation while preserving file
launchers.
- Detect GNU env split-string options before the assignment-only exit, scoped
to env option parsing so a target program's ordinary -S option stays
portable.
---
src/node/services/backup/payload.test.ts | 38 +++++++++++++++++-
src/node/services/backup/payload.ts | 50 ++++++++++++++++++++++--
2 files changed, 84 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index a0bcb5d9364..e162f459835 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1654,7 +1654,7 @@ describe("backup payload", () => {
// 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 cross-plane pair as one run without
+ // 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);
@@ -1668,6 +1668,37 @@ describe("backup payload", () => {
}
});
+ 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'"],
+ [
+ "Rscript expression operands",
+ `Rscript -e 'system(paste0("mcp",intToUtf8(32),"--token",intToUtf8(32),"ghp_Abcdef1234","Klmno56789"))'`,
+ ],
+ [
+ "GNU env split strings without assignments",
+ `env -S'mcp\\_--token\\_ghp_Abcdef1234""Klmno56789'`,
+ ],
+ ] 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 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
@@ -1832,6 +1863,11 @@ describe("backup payload", () => {
// the interpreter: server.py receives -c, Rails receives -e.
"python3 server.py -c settings.toml",
"ruby app.rb -e production",
+ "Rscript server.R --port 8080",
+ "npx notes-mcp --port 8080",
+ "npm exec notes-mcp -- --port 8080",
+ "mcp-server -Ssettings.toml",
+ "env -u TOKEN mcp-server -Ssettings.toml",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 0aad5608a14..5a631526db2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -144,7 +144,7 @@ function foldClassId(code: number): number {
* 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 cross-plane pairs like U+212A KELVIN SIGN and `k`).
+ * 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;
@@ -1594,6 +1594,16 @@ function isSplitStringOption(unquoted: string): boolean {
);
}
+/** GNU env's -u/--unset and -C/--chdir take a separate value in these spellings. */
+function envOptionTakesSeparateValue(unquoted: string): boolean {
+ if (unquoted === "-u" || unquoted === "-C") return true;
+ const abbreviation = /^--([A-Za-z-]+)$/.exec(unquoted)?.[1];
+ return (
+ abbreviation !== undefined &&
+ ("unset".startsWith(abbreviation) || "chdir".startsWith(abbreviation))
+ );
+}
+
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
@@ -1813,7 +1823,11 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
name: /^bun$/,
evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[A-Za-z0-9]*[ep])/,
},
+ // 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: /^(?:r|rscript)$/, evalWord: /^(?:-e|--expression(?:=|$))/ },
{ name: /^w?perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
{ name: /^rubyw?[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
@@ -1869,6 +1883,10 @@ const SHELL_STATE_WORDS = new Set([
function hasDisguisedAssignment(redacted: string): boolean {
let operandsOnly = false;
let pendingPrintfVariableOption = false;
+ let sawEnv = false;
+ let pendingEnvOptionValue = false;
+ let pendingNpmSubcommand = false;
+ let pendingNpmExecOptions = false;
let evalOperandAmbiguous = false;
// A Set of the static table's RegExp instances: repeated interpreter words cannot
// grow it past the table size, keeping this lookup linear in command length.
@@ -1882,6 +1900,32 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
+ // 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 (pendingEnvOptionValue) {
+ pendingEnvOptionValue = false;
+ } else if (isSplitStringOption(unquoted)) {
+ return true;
+ } else if (envOptionTakesSeparateValue(unquoted)) {
+ pendingEnvOptionValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ sawEnv = false;
+ }
+ }
+ 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 (!unquoted.startsWith("-")) {
+ pendingNpmSubcommand = false;
+ }
+ }
// 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;
@@ -1901,6 +1945,8 @@ function hasDisguisedAssignment(redacted: string): boolean {
.slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
.toLowerCase()
.replace(/\.exe$/, "");
+ if (executable === "env") sawEnv = true;
+ if (executable === "npm") pendingNpmSubcommand = true;
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;
@@ -1945,8 +1991,6 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (unquoted.startsWith("=")) return true;
// A quote-mangled `NAME=` spelling (`TOKEN\\=x`, `'TOKEN'=x`) for `env`/`eval`.
if (ASSIGNMENT_START.test(unquoted)) return true;
- // A split-string option with its value attached (`-STOKEN=x`, `--s=TOKEN=x`).
- if (isSplitStringOption(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
From 98855313d6e51b634a77675f088006ea8a117d1d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:05:49 +0000
Subject: [PATCH 067/116] fix: localize makefile launchers and tighten eval
option parsing
- Localize make/gmake/mingw make when -f/--file/--makefile or
-E/--eval evaluates a repository-selected makefile or statement, while
plain target launchers remain portable.
- Restrict interpreter short eval matches to options that can actually
precede the eval flag. Attached operands such as perl/ruby -Ivendor,
python -Wsource, node/bun -rvendor, and php -cvendor no longer trigger
redaction; real clusters including Python -Bc, Perl -0e/-pe, Ruby -0e,
and PHP -nr remain localized.
---
src/node/services/backup/payload.test.ts | 30 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 28 +++++++++++++++-------
2 files changed, 49 insertions(+), 9 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index e162f459835..cfc97d8a367 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1699,6 +1699,30 @@ describe("backup payload", () => {
});
}
+ it("localizes makefile-driven launchers", async () => {
+ for (const command of [
+ "make -f /home/user/.xum/skills/launch.txt",
+ "gmake --file=/home/user/.xum/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
@@ -1862,7 +1886,13 @@ describe("backup payload", () => {
// Dash-led words after the script/module operand belong to that program, not
// the interpreter: server.py receives -c, Rails receives -e.
"python3 server.py -c settings.toml",
+ "perl -Ivendor server.pl",
+ "python3 -Wsource server.py",
+ "node -rvendor server.js",
+ "bun -rvendor server.ts",
"ruby app.rb -e production",
+ "ruby -Ivendor app.rb",
+ "php -cvendor/php.ini server.php",
"Rscript server.R --port 8080",
"npx notes-mcp --port 8080",
"npm exec notes-mcp -- --port 8080",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 5a631526db2..faf03db425c 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1813,25 +1813,35 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
// 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.
- { name: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[A-Za-z0-9]*c/ },
+ // grammars under different executable names. Short eval flags can follow only flags
+ // that consume no attached operand: `-Bc` evaluates, while `-Wsource` does not.
+ { name: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/ },
{
name: /^(?:node|nodejs)$/,
- evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[A-Za-z0-9]*[ep])/,
+ evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[ep])/,
},
{
name: /^bun$/,
- evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[A-Za-z0-9]*[ep])/,
+ evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[ep])/,
},
// 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: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
- { name: /^(?:r|rscript)$/, evalWord: /^(?:-e|--expression(?:=|$))/ },
- { name: /^w?perl[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*[eE]/ },
- { name: /^rubyw?[0-9.]*$/, evalWord: /^-[A-Za-z0-9]*e/ },
+ { name: /^(?:r|rscript)$/, evalWord: /^(?:-e$|--expression(?:=|$))/ },
+ {
+ name: /^w?perl[0-9.]*$/,
+ evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
+ },
+ { name: /^rubyw?[0-9.]*$/, evalWord: /^-(?:(?:0[0-7]*|W[0-2]?)|[acdlnpsvwy])*e/ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
- { name: /^(?:php[0-9.]*|php-win)$/, evalWord: /^-[A-Za-z0-9]*[rRBE]/ },
+ { name: /^(?:php[0-9.]*|php-win)$/, evalWord: /^-[nq]*[rRBE]/ },
+ // 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(?:=|$))/,
+ },
];
/**
From b37f78f03e75ca85cedb25aa3815a99d8ade07c5 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:25:50 +0000
Subject: [PATCH 068/116] fix: localize runtime preloads and Git shell
callbacks
- Localize Node -r/--require and Bun -r/--preload/--require module
preloads, including attached forms. These modules execute before the main
script and can reconstruct credentials from auto-published files.
- Track Git subcommands that accept shell callbacks: submodule foreach,
rebase --exec/-x, and filter-branch. Ordinary Git commands, including
global -C paths and submodule status, remain portable.
---
src/node/services/backup/payload.test.ts | 55 +++++++++++++++++++++++-
src/node/services/backup/payload.ts | 37 +++++++++++++++-
2 files changed, 88 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index cfc97d8a367..98f0bd71b01 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1699,6 +1699,56 @@ describe("backup payload", () => {
});
}
+ it("localizes runtime preload modules", async () => {
+ for (const command of [
+ "node --require /home/user/.xum/skills/launch.txt server.js",
+ "node -r/home/user/.xum/skills/launch.txt server.js",
+ "bun --preload /home/user/.xum/skills/launch.txt server.ts",
+ "bun --require=/home/user/.xum/skills/launch.txt server.ts",
+ "bun -r/home/user/.xum/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 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 /home/user/.xum/skills/launch.txt",
@@ -1888,8 +1938,6 @@ describe("backup payload", () => {
"python3 server.py -c settings.toml",
"perl -Ivendor server.pl",
"python3 -Wsource server.py",
- "node -rvendor server.js",
- "bun -rvendor server.ts",
"ruby app.rb -e production",
"ruby -Ivendor app.rb",
"php -cvendor/php.ini server.php",
@@ -1898,6 +1946,9 @@ describe("backup payload", () => {
"npm exec notes-mcp -- --port 8080",
"mcp-server -Ssettings.toml",
"env -u TOKEN mcp-server -Ssettings.toml",
+ "git status",
+ "git -C /tmp status",
+ "git submodule status",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index faf03db425c..0fc23443f98 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1604,6 +1604,11 @@ function envOptionTakesSeparateValue(unquoted: string): boolean {
);
}
+/** 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);
+}
+
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
@@ -1818,11 +1823,13 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
{ name: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/ },
{
name: /^(?:node|nodejs)$/,
- evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[ep])/,
+ evalWord:
+ /^(?:(?:--eval|--print|--import|--loader|--experimental-loader|--require)(?:=|$)|-[epr])/,
},
{
name: /^bun$/,
- evalWord: /^(?:--eval|--print|--import|--loader|--experimental-loader|-[ep])/,
+ 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.
@@ -1897,6 +1904,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingEnvOptionValue = false;
let pendingNpmSubcommand = false;
let pendingNpmExecOptions = false;
+ let pendingGitSubcommand = false;
+ let pendingGitOptionValue = false;
+ let pendingGitSubmoduleAction = false;
+ let pendingGitRebaseOptions = false;
let evalOperandAmbiguous = false;
// A Set of the static table's RegExp instances: repeated interpreter words cannot
// grow it past the table size, keeping this lookup linear in command length.
@@ -1924,6 +1935,27 @@ function hasDisguisedAssignment(redacted: string): boolean {
sawEnv = false;
}
}
+ 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;
+ } else if (gitOptionTakesSeparateValue(unquoted)) {
+ pendingGitOptionValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingGitSubcommand = false;
+ if (unquoted === "submodule") {
+ pendingGitSubmoduleAction = true;
+ } else if (unquoted === "rebase") {
+ pendingGitRebaseOptions = true;
+ } else if (unquoted === "filter-branch") {
+ return true;
+ }
+ }
+ }
if (pendingNpmExecOptions) {
if (/^(?:-c|--call(?:=|$))/.test(unquoted)) return true;
if (!unquoted.startsWith("-")) pendingNpmExecOptions = false;
@@ -1957,6 +1989,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
.replace(/\.exe$/, "");
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
+ if (executable === "git") pendingGitSubcommand = true;
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;
From 7ba8c30fae5b1ac94e9e84158d4b93f521ac504a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 21:52:48 +0000
Subject: [PATCH 069/116] fix: bind interpreter commands to published script
inputs
- Localize recognized interpreters when their script operand points at an
automatically published document under the canonical or legacy settings
root, including Deno run after options and option values.
- Preserve npm subcommand tracking across global-option values until a known
command or alias is reached, so npm --prefix /tmp exec -c is localized
without misclassifying npm run/install arguments named exec.
---
src/node/services/backup/payload.test.ts | 31 +++++++++++++
src/node/services/backup/payload.ts | 55 +++++++++++++++++++-----
2 files changed, 76 insertions(+), 10 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 98f0bd71b01..b00468c9848 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1671,6 +1671,10 @@ describe("backup payload", () => {
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"))'`,
@@ -1699,6 +1703,31 @@ describe("backup payload", () => {
});
}
+ for (const [name, command] of [
+ ["Python", "python3 ~/.xum/skills/launch.txt"],
+ ["Node", "node /home/user/.xum/agents/launch.md"],
+ ["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
+ ["Deno", "deno run --config deno.json 'C:\\Users\\me\\.xum\\skills\\launch.mdx'"],
+ ] as const) {
+ it(`localizes ${name} execution of auto-published documents`, 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 runtime preload modules", async () => {
for (const command of [
"node --require /home/user/.xum/skills/launch.txt server.js",
@@ -1944,6 +1973,8 @@ describe("backup payload", () => {
"Rscript server.R --port 8080",
"npx notes-mcp --port 8080",
"npm exec notes-mcp -- --port 8080",
+ "npm --prefix /tmp install exec -c",
+ "npm --prefix /tmp run exec -c",
"mcp-server -Ssettings.toml",
"env -u TOKEN mcp-server -Ssettings.toml",
"git status",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 0fc23443f98..18c312dd5f4 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1609,6 +1609,31 @@ function gitOptionTakesSeparateValue(unquoted: string): boolean {
return /^(?:-[cC]|--(?:git-dir|work-tree|namespace|super-prefix|config-env))$/.test(unquoted);
}
+/**
+ * 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;
+
+function isAutoPublishedScriptOperand(unquoted: string): boolean {
+ const normalized = unquoted.replaceAll("\\", "/");
+ const relative = /(?:^|\/)\.(?:xum|mux)\/(.+)$/i.exec(normalized)?.[1];
+ if (relative === undefined) return false;
+ if (/^AGENTS\.md$/i.test(relative)) return true;
+ if (/^agents\/[^/]+\.md$/i.test(relative)) return true;
+ return (
+ /^(?:skills|memory\/global)\//i.test(relative) && AUTO_PUBLISHED_RECURSIVE_FILE.test(relative)
+ );
+}
+
+/** 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(
+ " "
+ )
+);
+
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
@@ -1908,6 +1933,8 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitOptionValue = false;
let pendingGitSubmoduleAction = false;
let pendingGitRebaseOptions = false;
+ let pendingDenoSubcommand = false;
+ let pendingDenoRunScript = false;
let evalOperandAmbiguous = false;
// A Set of the static table's RegExp instances: repeated interpreter words cannot
// grow it past the table size, keeping this lookup linear in command length.
@@ -1964,9 +1991,20 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (unquoted === "exec" || unquoted === "x") {
pendingNpmSubcommand = false;
pendingNpmExecOptions = true;
- } else if (!unquoted.startsWith("-")) {
+ } 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 && isAutoPublishedScriptOperand(unquoted)) return true;
+ if (pendingDenoSubcommand) {
+ if (unquoted === "run") {
+ pendingDenoSubcommand = false;
+ pendingDenoRunScript = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingDenoSubcommand = false;
+ }
}
// With allexport inherited through SHELLOPTS, `printf -v` exports the variable it
// builds even when this command contains no explicit export/set/shopt word.
@@ -1990,6 +2028,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
+ if (executable === "deno") pendingDenoSubcommand = true;
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;
@@ -2007,6 +2046,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
// (`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 (isAutoPublishedScriptOperand(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 belong to that program (`python3 server.py
@@ -2580,15 +2624,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;
From 6bf665ee44efd34586677eada5bbafae40f918ef Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:12:44 +0000
Subject: [PATCH 070/116] fix: inspect attached interpreter script-file options
R --file=PATH/-fPATH and PHP --file=PATH/-fPATH encode the script
operand inside a dash-led word, bypassing the positional auto-published
script check. Interpreter descriptors now declare attached and separate
file options; matching auto-published scripts localize, while non-published
script boundaries clear tracking so later .xum arguments are not mistaken
for executable inputs.
---
src/node/services/backup/payload.test.ts | 7 +++
src/node/services/backup/payload.ts | 70 ++++++++++++++++++++----
2 files changed, 65 insertions(+), 12 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index b00468c9848..bdebf9f6f6a 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1707,6 +1707,10 @@ describe("backup payload", () => {
["Python", "python3 ~/.xum/skills/launch.txt"],
["Node", "node /home/user/.xum/agents/launch.md"],
["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
+ ["R attached file option", "R --file=/home/alice/.xum/skills/launch.txt"],
+ ["R separate file option", "R -f ~/.xum/skills/launch.txt"],
+ ["PHP attached file option", "php --file=/home/alice/.xum/skills/launch.mdx"],
+ ["PHP separate file option", "php -f ~/.mux/memory/global/launch.markdown"],
["Deno", "deno run --config deno.json 'C:\\Users\\me\\.xum\\skills\\launch.mdx'"],
] as const) {
it(`localizes ${name} execution of auto-published documents`, async () => {
@@ -1971,6 +1975,9 @@ describe("backup payload", () => {
"ruby -Ivendor app.rb",
"php -cvendor/php.ini server.php",
"Rscript server.R --port 8080",
+ "R --file=/tmp/server.R ~/.xum/skills/argument.txt",
+ "R -f /tmp/server.R ~/.xum/skills/argument.txt",
+ "php --file=/tmp/server.php ~/.xum/skills/argument.txt",
"npx notes-mcp --port 8080",
"npm exec notes-mcp -- --port 8080",
"npm --prefix /tmp install exec -c",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 18c312dd5f4..56e5c3c68ce 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1840,7 +1840,14 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
* numeric `-0[octal]` switch before the eval letter (`-0e`). Which letters evaluate
* is per-interpreter knowledge this table owns, unlike arbitrary programs' options.
*/
-const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
+interface LanguageInterpreter {
+ name: RegExp;
+ evalWord: RegExp;
+ attachedScriptFile?: RegExp;
+ separateScriptFileOption?: RegExp;
+}
+
+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
@@ -1860,14 +1867,25 @@ const LANGUAGE_INTERPRETERS: Array<{ name: RegExp; evalWord: RegExp }> = [
// through a shell. npm needs its `exec` subcommand tracked separately below.
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
- { name: /^(?:r|rscript)$/, evalWord: /^(?:-e$|--expression(?:=|$))/ },
+ {
+ name: /^r$/,
+ evalWord: /^(?:-e$|--expression(?:=|$))/,
+ attachedScriptFile: /^(?:--file=|-f)(.+)$/,
+ separateScriptFileOption: /^-f$/,
+ },
+ { name: /^rscript$/, evalWord: /^(?:-e$|--expression(?:=|$))/ },
{
name: /^w?perl[0-9.]*$/,
evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
},
{ name: /^rubyw?[0-9.]*$/, evalWord: /^-(?:(?:0[0-7]*|W[0-2]?)|[acdlnpsvwy])*e/ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
- { name: /^(?:php[0-9.]*|php-win)$/, evalWord: /^-[nq]*[rRBE]/ },
+ {
+ name: /^(?:php[0-9.]*|php-win)$/,
+ evalWord: /^-[nq]*[rRBE]/,
+ attachedScriptFile: /^(?:--file=|-f)(.+)$/,
+ separateScriptFileOption: /^(?:-f|--file)$/,
+ },
// 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.
{
@@ -1935,10 +1953,17 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitRebaseOptions = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
+ let pendingScriptFileOperand = false;
let evalOperandAmbiguous = false;
- // A Set of the static table's RegExp instances: repeated interpreter words cannot
- // grow it past the table size, keeping this lookup linear in command length.
- const pendingEvalWords = new Set();
+ // Static table entries keep the pending set bounded, so repeated interpreter words
+ // cannot make these checks superlinear in command length.
+ const pendingLanguages = new Set();
+
+ function clearInterpreterTracking(): void {
+ pendingLanguages.clear();
+ pendingScriptFileOperand = false;
+ evalOperandAmbiguous = false;
+ }
for (const word of redacted.match(SHELL_WORD) ?? []) {
if (CONSUMED_ASSIGNMENT.test(word)) continue;
// Bash expands neither syntax from quoted or escaped text (`--config
@@ -1948,6 +1973,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (hasActiveBraceExpansion(activeWordProjection(word))) return true;
if (hasNondeterministicGlob(word)) return true;
const unquoted = unquoteShellWord(word);
+ if (pendingScriptFileOperand) {
+ const autoPublished = isAutoPublishedScriptOperand(unquoted);
+ clearInterpreterTracking();
+ if (autoPublished) return 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.
@@ -2032,15 +2062,31 @@ function hasDisguisedAssignment(redacted: string): boolean {
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;
- // An evaluation word after a language interpreter hands that grammar a script.
- for (const pattern of pendingEvalWords) {
- if (pattern.test(unquoted)) 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;
+ for (const pending of pendingLanguages) {
+ const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
+ if (script !== undefined) {
+ if (isAutoPublishedScriptOperand(script)) return true;
+ attachedScriptBoundary = true;
+ break;
+ }
+ if (pending.separateScriptFileOption?.test(unquoted) === true) {
+ pendingScriptFileOperand = true;
+ }
+ // An evaluation word after a language interpreter hands that grammar a script.
+ if (pending.evalWord.test(unquoted)) return true;
}
+ if (attachedScriptBoundary) clearInterpreterTracking();
+
const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
if (language) {
- pendingEvalWords.add(language.evalWord);
+ pendingLanguages.add(language);
evalOperandAmbiguous = false;
- } else if (pendingEvalWords.size > 0) {
+ } 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
@@ -2056,7 +2102,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// operand: later dash-led words 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.
- pendingEvalWords.clear();
+ clearInterpreterTracking();
}
}
// Option terminators end option parsing: past one even a dash-led word is an
From 890c6adddff4bc4d6468b3c8dc8915ab41b148de Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:17:22 +0000
Subject: [PATCH 071/116] fix: cover APIM subscription keys and Tcl script
launchers
- Treat subscription_key and Ocp-Apim-Subscription-Key query aliases as
credentials in the shared repository/MCP URL predicate.
- Recognize Tcl-family interpreters (tclsh, wish, expect, jimsh) so script
operands that reference automatically published documents are localized,
while external script paths remain portable.
---
src/common/config/schemas/appConfigOnDisk.test.ts | 1 +
src/common/config/schemas/settingsBackup.ts | 2 ++
src/node/services/backup/payload.test.ts | 8 ++++++++
src/node/services/backup/payload.ts | 7 +++++--
4 files changed, 16 insertions(+), 2 deletions(-)
diff --git a/src/common/config/schemas/appConfigOnDisk.test.ts b/src/common/config/schemas/appConfigOnDisk.test.ts
index 3f8e107a4ca..598b14b2eee 100644
--- a/src/common/config/schemas/appConfigOnDisk.test.ts
+++ b/src/common/config/schemas/appConfigOnDisk.test.ts
@@ -142,6 +142,7 @@ describe("AppConfigOnDiskSchema", () => {
"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);
diff --git a/src/common/config/schemas/settingsBackup.ts b/src/common/config/schemas/settingsBackup.ts
index 58e705cb116..5d3cdff644e 100644
--- a/src/common/config/schemas/settingsBackup.ts
+++ b/src/common/config/schemas/settingsBackup.ts
@@ -116,6 +116,8 @@ export const CREDENTIAL_URL_PARAMETER_NAMES: ReadonlySet = new Set([
"securitytoken",
"sessionid",
"sessiontoken",
+ "subscriptionkey",
+ "ocpapimsubscriptionkey",
"signature",
"token",
"xamzcredential",
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index bdebf9f6f6a..7a20055c735 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1707,6 +1707,9 @@ describe("backup payload", () => {
["Python", "python3 ~/.xum/skills/launch.txt"],
["Node", "node /home/user/.xum/agents/launch.md"],
["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
+ ["Tcl", "tclsh ~/.xum/skills/launch.txt"],
+ ["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
+ ["Expect", "expect ~/.mux/memory/global/launch.txt"],
["R attached file option", "R --file=/home/alice/.xum/skills/launch.txt"],
["R separate file option", "R -f ~/.xum/skills/launch.txt"],
["PHP attached file option", "php --file=/home/alice/.xum/skills/launch.mdx"],
@@ -1975,6 +1978,9 @@ describe("backup payload", () => {
"ruby -Ivendor app.rb",
"php -cvendor/php.ini server.php",
"Rscript server.R --port 8080",
+ "tclsh /tmp/server.tcl",
+ "wish8.6 /tmp/app.tcl",
+ "expect /tmp/session.exp",
"R --file=/tmp/server.R ~/.xum/skills/argument.txt",
"R -f /tmp/server.R ~/.xum/skills/argument.txt",
"php --file=/tmp/server.php ~/.xum/skills/argument.txt",
@@ -4579,6 +4585,8 @@ describe("backup payload", () => {
"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
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 56e5c3c68ce..c707588f00a 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1842,7 +1842,7 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
*/
interface LanguageInterpreter {
name: RegExp;
- evalWord: RegExp;
+ evalWord?: RegExp;
attachedScriptFile?: RegExp;
separateScriptFileOption?: RegExp;
}
@@ -1867,6 +1867,9 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// through a shell. npm needs its `exec` subcommand tracked separately below.
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
+ // Tcl-family launchers execute a positional script but have no inline-eval option
+ // needed here; auto-published script operands still localize through the shared check.
+ { name: /^(?:tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
@@ -2078,7 +2081,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingScriptFileOperand = true;
}
// An evaluation word after a language interpreter hands that grammar a script.
- if (pending.evalWord.test(unquoted)) return true;
+ if (pending.evalWord?.test(unquoted) === true) return true;
}
if (attachedScriptBoundary) clearInterpreterTracking();
From 886dac61644314c708ff09340f2fcfea75d307d1 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:31:59 +0000
Subject: [PATCH 072/116] fix: honor interpreter terminators and track Lua
evaluation
- Process bare/double-dash script and option terminators before evaluator
matching, clearing interpreter, npm-exec, env, and git-rebase option
state so following dash-led operands are not mistaken for evaluators.
Assignment-only mode is now scoped to GNU env terminators.
- Recognize versioned Lua/LuaJIT -e evaluation and auto-published script
operands while preserving ordinary external Lua files.
---
src/node/services/backup/payload.test.ts | 17 ++++++++++++++++
src/node/services/backup/payload.ts | 26 +++++++++++++++---------
2 files changed, 33 insertions(+), 10 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 7a20055c735..609d9757f79 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1679,6 +1679,10 @@ describe("backup payload", () => {
"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")'`,
+ ],
[
"GNU env split strings without assignments",
`env -S'mcp\\_--token\\_ghp_Abcdef1234""Klmno56789'`,
@@ -1707,6 +1711,8 @@ describe("backup payload", () => {
["Python", "python3 ~/.xum/skills/launch.txt"],
["Node", "node /home/user/.xum/agents/launch.md"],
["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
+ ["Lua", "lua5.4 ~/.xum/skills/launch.txt"],
+ ["LuaJIT", "luajit /home/user/.xum/agents/launch.md"],
["Tcl", "tclsh ~/.xum/skills/launch.txt"],
["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
["Expect", "expect ~/.mux/memory/global/launch.txt"],
@@ -1971,6 +1977,15 @@ describe("backup payload", () => {
"python3 -m mcp_server --port 8080",
// Dash-led words after the script/module operand belong to that program, not
// the interpreter: server.py receives -c, Rails receives -e.
+ "python3 -- -c",
+ "python3 - -c",
+ "node -- --require",
+ "bun -- --preload",
+ "R -- --file=~/.xum/skills/launch.txt",
+ "php -- --file=~/.xum/skills/launch.txt",
+ "make -- -f",
+ "npm exec -- -c",
+ "env -- -Ssettings",
"python3 server.py -c settings.toml",
"perl -Ivendor server.pl",
"python3 -Wsource server.py",
@@ -1978,6 +1993,8 @@ describe("backup payload", () => {
"ruby -Ivendor app.rb",
"php -cvendor/php.ini server.php",
"Rscript server.R --port 8080",
+ "lua /tmp/server.lua",
+ "luajit /tmp/server.lua",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
"expect /tmp/session.exp",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c707588f00a..e26722b8f6c 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1867,6 +1867,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// through a shell. npm needs its `exec` subcommand tracked separately below.
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
+ { name: /^(?:lua|luajit)[0-9.]*$/, evalWord: /^-e/ },
// Tcl-family launchers execute a positional script but have no inline-eval option
// needed here; auto-published script operands still localize through the shared check.
{ name: /^(?:tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
@@ -1944,7 +1945,7 @@ const SHELL_STATE_WORDS = new Set([
* about the rest of that word.
*/
function hasDisguisedAssignment(redacted: string): boolean {
- let operandsOnly = false;
+ let envOperandsOnly = false;
let pendingPrintfVariableOption = false;
let sawEnv = false;
let pendingEnvOptionValue = false;
@@ -1981,6 +1982,19 @@ function hasDisguisedAssignment(redacted: string): boolean {
clearInterpreterTracking();
if (autoPublished) return true;
}
+ // A bare dash is a script operand for interpreters; double dash ends their option
+ // parsing. Handle both before eval matching so the following dash-led filename or
+ // argument is never mistaken for an evaluator. They also terminate env options,
+ // npm exec call options, and git rebase options at this parse level.
+ if (unquoted === "-" || unquoted === "--") {
+ clearInterpreterTracking();
+ envOperandsOnly ||= sawEnv;
+ sawEnv = false;
+ pendingEnvOptionValue = false;
+ pendingNpmExecOptions = false;
+ pendingGitRebaseOptions = false;
+ continue;
+ }
// 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.
@@ -2108,21 +2122,13 @@ function hasDisguisedAssignment(redacted: string): boolean {
clearInterpreterTracking();
}
}
- // Option terminators end option parsing: past one even a dash-led word is an
- // operand, so `env -- --evil=x` sets an environment entry despite the option look.
- // GNU `env` documents `[-]` as a terminator too, and the consumer sees the word
- // after quote removal, so `"--"` and `\-\-` spellings count as well.
- if (unquoted === "-" || unquoted === "--") {
- operandsOnly = true;
- continue;
- }
// 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 (operandsOnly) return true;
+ 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`.
From 8097f7bcff07882c74bd86f318198d026bab7e41 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:35:32 +0000
Subject: [PATCH 073/116] fix: localize PHP process-file script inputs
PHP -F/--process-file executes the selected file for each input line. Add
attached and separate spellings to the shared script-file operand handling
so auto-published documents localize, while non-published process files end
interpreter tracking before later arguments.
---
src/node/services/backup/payload.test.ts | 5 +++++
src/node/services/backup/payload.ts | 4 ++--
2 files changed, 7 insertions(+), 2 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 609d9757f79..20409057e30 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1720,6 +1720,9 @@ describe("backup payload", () => {
["R separate file option", "R -f ~/.xum/skills/launch.txt"],
["PHP attached file option", "php --file=/home/alice/.xum/skills/launch.mdx"],
["PHP separate file option", "php -f ~/.mux/memory/global/launch.markdown"],
+ ["PHP process-file option", "php -F/home/alice/.xum/skills/launch.txt"],
+ ["PHP long process-file option", "php --process-file=~/.xum/skills/launch.txt"],
+ ["PHP separate process-file option", "php --process-file ~/.xum/skills/launch.txt"],
["Deno", "deno run --config deno.json 'C:\\Users\\me\\.xum\\skills\\launch.mdx'"],
] as const) {
it(`localizes ${name} execution of auto-published documents`, async () => {
@@ -2001,6 +2004,8 @@ describe("backup payload", () => {
"R --file=/tmp/server.R ~/.xum/skills/argument.txt",
"R -f /tmp/server.R ~/.xum/skills/argument.txt",
"php --file=/tmp/server.php ~/.xum/skills/argument.txt",
+ "php -F/tmp/process.php ~/.xum/skills/argument.txt",
+ "php --process-file /tmp/process.php ~/.xum/skills/argument.txt",
"npx notes-mcp --port 8080",
"npm exec notes-mcp -- --port 8080",
"npm --prefix /tmp install exec -c",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e26722b8f6c..b432b643f7d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1887,8 +1887,8 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{
name: /^(?:php[0-9.]*|php-win)$/,
evalWord: /^-[nq]*[rRBE]/,
- attachedScriptFile: /^(?:--file=|-f)(.+)$/,
- separateScriptFileOption: /^(?:-f|--file)$/,
+ attachedScriptFile: /^(?:--file=|--process-file=|-[fF])(.+)$/,
+ separateScriptFileOption: /^(?:-[fF]|--file|--process-file)$/,
},
// 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.
From d0fe82e0b20d6a89256adf2931bc6be0543ad648 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:46:52 +0000
Subject: [PATCH 074/116] fix: localize JShell load-file inputs
JShell executes positional load files as Java code even when they use a
.txt extension. Add it to positional-script interpreter tracking so
references to automatically published .xum/.mux documents localize, while
ordinary external .jsh inputs remain portable.
---
src/node/services/backup/payload.test.ts | 2 ++
src/node/services/backup/payload.ts | 6 +++---
2 files changed, 5 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 20409057e30..f1612476ee5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1713,6 +1713,7 @@ describe("backup payload", () => {
["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
["Lua", "lua5.4 ~/.xum/skills/launch.txt"],
["LuaJIT", "luajit /home/user/.xum/agents/launch.md"],
+ ["JShell", "jshell ~/.xum/skills/launch.txt"],
["Tcl", "tclsh ~/.xum/skills/launch.txt"],
["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
["Expect", "expect ~/.mux/memory/global/launch.txt"],
@@ -1998,6 +1999,7 @@ describe("backup payload", () => {
"Rscript server.R --port 8080",
"lua /tmp/server.lua",
"luajit /tmp/server.lua",
+ "jshell /tmp/launch.jsh",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
"expect /tmp/session.exp",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index b432b643f7d..ffaa0726a5d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1868,9 +1868,9 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
{ name: /^(?:lua|luajit)[0-9.]*$/, evalWord: /^-e/ },
- // Tcl-family launchers execute a positional script but have no inline-eval option
- // needed here; auto-published script operands still localize through the shared check.
- { name: /^(?:tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
+ // These launchers execute a positional script but need no inline-eval matcher here;
+ // auto-published script operands still localize through the shared check.
+ { name: /^(?:jshell|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
From 3fb81982e874c18aa10b23fad4fad9513f0d7ec9 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 22:51:57 +0000
Subject: [PATCH 075/116] fix: localize Git config shell aliases
Track git config's option values and key/value boundary. When an alias.*
value begins with !, Git later executes it through a shell, so the command
is localized. Ordinary aliases, reads, and non-shell alias values remain
portable.
---
src/node/services/backup/payload.test.ts | 25 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 26 +++++++++++++++++++++++-
2 files changed, 50 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index f1612476ee5..bf4b8aa555f 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1771,6 +1771,29 @@ describe("backup payload", () => {
}
});
+ it("localizes Git shell aliases installed through config", 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'",
+ ]) {
+ 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'",
@@ -2017,6 +2040,8 @@ describe("backup payload", () => {
"git status",
"git -C /tmp status",
"git submodule status",
+ "git config alias.co checkout",
+ "git config --get alias.co",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index ffaa0726a5d..c4486ce155d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1609,6 +1609,11 @@ 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);
+}
+
/**
* Documentation is the only thing a recursive collection publishes without asking.
* An interpreter that executes one of these files can reconstruct a credential across
@@ -1955,6 +1960,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitOptionValue = false;
let pendingGitSubmoduleAction = false;
let pendingGitRebaseOptions = false;
+ let pendingGitConfigKey = false;
+ let pendingGitConfigOptionValue = false;
+ let pendingGitAliasValue = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
let pendingScriptFileOperand = false;
@@ -2009,6 +2017,20 @@ function hasDisguisedAssignment(redacted: string): boolean {
sawEnv = false;
}
}
+ if (pendingGitAliasValue) {
+ pendingGitAliasValue = false;
+ if (unquoted.startsWith("!")) 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;
+ }
+ }
if (pendingGitSubmoduleAction) {
if (unquoted === "foreach") return true;
if (!unquoted.startsWith("-")) pendingGitSubmoduleAction = false;
@@ -2021,7 +2043,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingGitOptionValue = true;
} else if (!unquoted.startsWith("-")) {
pendingGitSubcommand = false;
- if (unquoted === "submodule") {
+ if (unquoted === "config") {
+ pendingGitConfigKey = true;
+ } else if (unquoted === "submodule") {
pendingGitSubmoduleAction = true;
} else if (unquoted === "rebase") {
pendingGitRebaseOptions = true;
From f4f26dee904a1569fe1a6a437c28e3e1b26c1221 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:08:44 +0000
Subject: [PATCH 076/116] fix: localize direct execution of published documents
Detect automatically published .xum/.mux documents in shell command
position, including after environment assignments and shell operators.
Such files can execute through a shebang and reconstruct credentials across
the command/file boundary; the same path remains portable when passed as
an ordinary argument.
---
src/node/services/backup/payload.test.ts | 25 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 21 ++++++++++++++++++++
2 files changed, 46 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index bf4b8aa555f..979f647eb10 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1707,6 +1707,30 @@ describe("backup payload", () => {
});
}
+ it("localizes directly executed auto-published documents", async () => {
+ for (const command of [
+ "~/.xum/skills/launch.txt",
+ "MODE=fast ~/.xum/skills/launch.txt",
+ "true; /home/user/.xum/agents/launch.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);
+ }
+ });
+
for (const [name, command] of [
["Python", "python3 ~/.xum/skills/launch.txt"],
["Node", "node /home/user/.xum/agents/launch.md"],
@@ -2022,6 +2046,7 @@ describe("backup payload", () => {
"Rscript server.R --port 8080",
"lua /tmp/server.lua",
"luajit /tmp/server.lua",
+ "mcp-server --config ~/.xum/skills/launch.txt",
"jshell /tmp/launch.jsh",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c4486ce155d..789f2ced58d 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1642,6 +1642,26 @@ const NPM_SUBCOMMANDS = new Set(
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
+/**
+ * A directly named auto-published document can be executable through its shebang. Track
+ * command starts through assignments and active shell operators, but leave the same path
+ * portable when it is merely an argument to another program.
+ */
+function hasDirectAutoPublishedCommand(redacted: string): boolean {
+ let commandPosition = true;
+ let previousEnd = 0;
+ for (const match of redacted.matchAll(SHELL_WORD)) {
+ const start = match.index;
+ if (/[;&|()\n]/.test(redacted.slice(previousEnd, start))) commandPosition = true;
+ previousEnd = start + match[0].length;
+ if (!commandPosition) continue;
+ if (CONSUMED_ASSIGNMENT.test(match[0])) continue;
+ if (isAutoPublishedScriptOperand(unquoteShellWord(match[0]))) return true;
+ commandPosition = false;
+ }
+ return false;
+}
+
/**
* 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
@@ -1950,6 +1970,7 @@ const SHELL_STATE_WORDS = new Set([
* about the rest of that word.
*/
function hasDisguisedAssignment(redacted: string): boolean {
+ if (hasDirectAutoPublishedCommand(redacted)) return true;
let envOperandsOnly = false;
let pendingPrintfVariableOption = false;
let sawEnv = false;
From 21434d5a179a9f6fe9e0ed30639ba65b6e6e2486 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:13:12 +0000
Subject: [PATCH 077/116] fix: cover Swift, Elixir, and Git command config
values
- Track Swift and Elixir as positional-script runtimes so auto-published
.xum/.mux documents localize while external scripts remain portable.
- Localize Git config values that Git later executes as commands or helpers,
including core.sshCommand, credential helpers, filter processes, editors,
pagers, tools, GPG programs, and archive/man commands.
---
src/node/services/backup/payload.test.ts | 11 ++++++++++-
src/node/services/backup/payload.ts | 17 +++++++++++++++--
2 files changed, 25 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 979f647eb10..dfe7724d240 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1737,6 +1737,8 @@ describe("backup payload", () => {
["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
["Lua", "lua5.4 ~/.xum/skills/launch.txt"],
["LuaJIT", "luajit /home/user/.xum/agents/launch.md"],
+ ["Swift", "swift ~/.xum/skills/launch.txt"],
+ ["Elixir", "elixir /home/user/.xum/agents/launch.md"],
["JShell", "jshell ~/.xum/skills/launch.txt"],
["Tcl", "tclsh ~/.xum/skills/launch.txt"],
["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
@@ -1795,10 +1797,13 @@ describe("backup payload", () => {
}
});
- it("localizes Git shell aliases installed through config", async () => {
+ 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 config credential.helper '!mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
+ "git config filter.secret.process 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'",
]) {
await writeFixtureFile(
muxRoot,
@@ -2047,6 +2052,8 @@ describe("backup payload", () => {
"lua /tmp/server.lua",
"luajit /tmp/server.lua",
"mcp-server --config ~/.xum/skills/launch.txt",
+ "swift /tmp/launch.swift",
+ "elixir /tmp/launch.exs",
"jshell /tmp/launch.jsh",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
@@ -2067,6 +2074,8 @@ describe("backup payload", () => {
"git submodule status",
"git config alias.co checkout",
"git config --get alias.co",
+ "git config core.sshCommand",
+ "git config user.name Alice",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 789f2ced58d..a8bf1c46cef 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1614,6 +1614,10 @@ function gitConfigOptionTakesSeparateValue(unquoted: string): boolean {
return /^(?:-[ft]|--(?:file|blob|type|comment|default))$/.test(unquoted);
}
+/** Git config values that Git later executes as commands or helper processes. */
+const GIT_COMMAND_CONFIG_KEY =
+ /^(?:core\.(?:sshcommand|askpass|editor|pager|gitproxy|fsmonitor)|sequence\.editor|diff\.external|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|pager\.[^.]+|(?:diff|merge)tool\.[^.]+\.cmd|filter\.[^.]+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\.[^.]+\.cmd|tar\.[^.]+\.command)$/i;
+
/**
* Documentation is the only thing a recursive collection publishes without asking.
* An interpreter that executes one of these files can reconstruct a credential across
@@ -1895,7 +1899,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^(?:lua|luajit)[0-9.]*$/, evalWord: /^-e/ },
// These launchers execute a positional script but need no inline-eval matcher here;
// auto-published script operands still localize through the shared check.
- { name: /^(?:jshell|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
+ { name: /^(?:elixir|jshell|swift|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
@@ -1984,6 +1988,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitConfigKey = false;
let pendingGitConfigOptionValue = false;
let pendingGitAliasValue = false;
+ let pendingGitCommandValue = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
let pendingScriptFileOperand = false;
@@ -2042,6 +2047,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingGitAliasValue = false;
if (unquoted.startsWith("!")) return true;
}
+ if (pendingGitCommandValue) {
+ pendingGitCommandValue = false;
+ return true;
+ }
if (pendingGitConfigKey) {
if (pendingGitConfigOptionValue) {
pendingGitConfigOptionValue = false;
@@ -2049,7 +2058,11 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingGitConfigOptionValue = true;
} else if (!unquoted.startsWith("-")) {
pendingGitConfigKey = false;
- if (/^alias\.[^.]+$/i.test(unquoted)) pendingGitAliasValue = true;
+ if (/^alias\.[^.]+$/i.test(unquoted)) {
+ pendingGitAliasValue = true;
+ } else if (GIT_COMMAND_CONFIG_KEY.test(unquoted)) {
+ pendingGitCommandValue = true;
+ }
}
}
if (pendingGitSubmoduleAction) {
From 7398417be6eb802cefb312552d4540052a9e18b7 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:31:06 +0000
Subject: [PATCH 078/116] fix: close env wrapper and encoded documentation gaps
- Preserve shell command-position tracking through nested GNU env wrappers,
their assignments, terminators, and argument-taking options so directly
executed auto-published documents still localize.
- Parse only no-argument env short flags before clustered -S, preventing
-uSESSION/-CSESSION/-aSESSION operands from triggering split-string.
- Scan one-pass percent-decoded published documentation for hard credential
formats using a chunked decoder (64 MiB: ~6.9s to under 1s on Node).
---
src/node/services/backup/payload.test.ts | 23 ++++++
src/node/services/backup/payload.ts | 99 +++++++++++++++++++++---
2 files changed, 113 insertions(+), 9 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index dfe7724d240..6f0fc107510 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1687,6 +1687,7 @@ describe("backup payload", () => {
"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'`],
] as const) {
it(`localizes ${name}`, async () => {
await writeFixtureFile(
@@ -1711,6 +1712,9 @@ describe("backup payload", () => {
for (const command of [
"~/.xum/skills/launch.txt",
"MODE=fast ~/.xum/skills/launch.txt",
+ "env ~/.xum/skills/launch.txt",
+ "env -u TOKEN ~/.xum/skills/launch.txt",
+ "env env /home/user/.xum/agents/launch.md",
"true; /home/user/.xum/agents/launch.md",
]) {
await writeFixtureFile(
@@ -2069,6 +2073,9 @@ describe("backup payload", () => {
"npm --prefix /tmp run exec -c",
"mcp-server -Ssettings.toml",
"env -u TOKEN mcp-server -Ssettings.toml",
+ "env -uSESSION mcp-server",
+ "env -CSESSION mcp-server",
+ "env -aSESSION mcp-server",
"git status",
"git -C /tmp status",
"git submodule status",
@@ -5537,6 +5544,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 a8bf1c46cef..4713de5c8df 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1587,20 +1587,24 @@ function executedShellWords(text: string): string[] {
* with `s`, so every `--s...` prefix spelling (`--s=`, `--split=`) resolves to it.
*/
function isSplitStringOption(unquoted: string): boolean {
- if (/^-[A-Za-z0-9]*S/.test(unquoted)) return true;
+ // -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's -u/--unset and -C/--chdir take a separate value in these spellings. */
+/** GNU env options whose following word is an option value, not COMMAND. */
function envOptionTakesSeparateValue(unquoted: string): boolean {
- if (unquoted === "-u" || unquoted === "-C") return true;
+ 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))
+ ("unset".startsWith(abbreviation) ||
+ "chdir".startsWith(abbreviation) ||
+ "argv0".startsWith(abbreviation))
);
}
@@ -1653,14 +1657,50 @@ const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VAL
*/
function hasDirectAutoPublishedCommand(redacted: string): boolean {
let commandPosition = true;
+ let envWrapper = false;
+ let envOptionValue = false;
let previousEnd = 0;
for (const match of redacted.matchAll(SHELL_WORD)) {
const start = match.index;
- if (/[;&|()\n]/.test(redacted.slice(previousEnd, start))) commandPosition = true;
+ if (/[;&|()\n]/.test(redacted.slice(previousEnd, start))) {
+ commandPosition = true;
+ envWrapper = false;
+ envOptionValue = false;
+ }
previousEnd = start + match[0].length;
if (!commandPosition) continue;
if (CONSUMED_ASSIGNMENT.test(match[0])) continue;
- if (isAutoPublishedScriptOperand(unquoteShellWord(match[0]))) return true;
+ const unquoted = unquoteShellWord(match[0]);
+ if (envWrapper) {
+ if (envOptionValue) {
+ envOptionValue = false;
+ continue;
+ }
+ if (unquoted === "-" || unquoted === "--") continue;
+ if (envOptionTakesSeparateValue(unquoted)) {
+ envOptionValue = true;
+ continue;
+ }
+ if (unquoted.startsWith("-")) continue;
+ const executable = unquoted
+ .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
+ .toLowerCase()
+ .replace(/\.exe$/, "");
+ if (executable === "env") continue;
+ if (isAutoPublishedScriptOperand(unquoted)) return true;
+ commandPosition = false;
+ envWrapper = false;
+ continue;
+ }
+ const executable = unquoted
+ .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
+ .toLowerCase()
+ .replace(/\.exe$/, "");
+ if (executable === "env") {
+ envWrapper = true;
+ continue;
+ }
+ if (isAutoPublishedScriptOperand(unquoted)) return true;
commandPosition = false;
}
return false;
@@ -2891,6 +2931,14 @@ export async function createBackupPayload(
// 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.
@@ -3963,10 +4011,43 @@ function collectUrlStrings(root: unknown): string[] {
* 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 {
- return text.replace(/%([0-9a-fA-F]{2})/g, (_match, hex: string) =>
- String.fromCharCode(Number.parseInt(hex, 16))
- );
+ 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("");
}
/**
From af572f4b12eb1f6ef5c76ff012fd1b669ed2ff24 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:44:26 +0000
Subject: [PATCH 079/116] fix: track Elixir and IEx evaluation operands
Elixir and IEx evaluate -e/--eval and --rpc-eval operands in addition to
executing positional scripts. Add those evaluator spellings while retaining
the shared auto-published script check and external-script portability.
---
src/node/services/backup/payload.test.ts | 9 +++++++++
src/node/services/backup/payload.ts | 3 ++-
2 files changed, 11 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 6f0fc107510..28695912597 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1683,6 +1683,14 @@ describe("backup payload", () => {
"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'`,
@@ -2058,6 +2066,7 @@ describe("backup payload", () => {
"mcp-server --config ~/.xum/skills/launch.txt",
"swift /tmp/launch.swift",
"elixir /tmp/launch.exs",
+ "iex /tmp/launch.exs",
"jshell /tmp/launch.jsh",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 4713de5c8df..4ddf7aa984c 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1937,9 +1937,10 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
{ name: /^(?:lua|luajit)[0-9.]*$/, evalWord: /^-e/ },
+ { name: /^(?:elixir|iex)[0-9.]*$/, evalWord: /^(?:-e$|--eval(?:=|$)|--rpc-eval(?:=|$))/ },
// These launchers execute a positional script but need no inline-eval matcher here;
// auto-published script operands still localize through the shared check.
- { name: /^(?:elixir|jshell|swift|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
+ { name: /^(?:jshell|swift|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
From 792844e3d29a2d715dc06c918d1d52cf0c020b0e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Thu, 27 Aug 2026 23:51:52 +0000
Subject: [PATCH 080/116] fix: track Java source-file launcher mode
Track java --source VERSION and --source=VERSION through launcher options
and their values to the actual source-file operand. Auto-published .xum/.mux
documents localize; external source files end tracking before later args.
---
src/node/services/backup/payload.test.ts | 7 +++++
src/node/services/backup/payload.ts | 37 ++++++++++++++++++++++++
2 files changed, 44 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 28695912597..7fa180f7936 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1751,6 +1751,12 @@ describe("backup payload", () => {
["LuaJIT", "luajit /home/user/.xum/agents/launch.md"],
["Swift", "swift ~/.xum/skills/launch.txt"],
["Elixir", "elixir /home/user/.xum/agents/launch.md"],
+ ["Java source mode", "java --source 17 ~/.xum/skills/launch.txt"],
+ ["Java attached source mode", "java --source=17 /home/user/.xum/agents/launch.md"],
+ [
+ "Java source mode with option values",
+ "java --class-path libs --source 17 --module-path mods ~/.xum/skills/launch.txt",
+ ],
["JShell", "jshell ~/.xum/skills/launch.txt"],
["Tcl", "tclsh ~/.xum/skills/launch.txt"],
["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
@@ -2067,6 +2073,7 @@ describe("backup payload", () => {
"swift /tmp/launch.swift",
"elixir /tmp/launch.exs",
"iex /tmp/launch.exs",
+ "java --source 17 /tmp/Main.java",
"jshell /tmp/launch.jsh",
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 4ddf7aa984c..e0116ec5657 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1618,6 +1618,13 @@ function gitConfigOptionTakesSeparateValue(unquoted: string): boolean {
return /^(?:-[ft]|--(?:file|blob|type|comment|default))$/.test(unquoted);
}
+/** Java launcher options whose following word is an option value, not the source file. */
+function javaOptionTakesSeparateValue(unquoted: string): boolean {
+ return /^(?:-cp|-classpath|-p|--(?:class-path|module-path|upgrade-module-path|add-modules|enable-native-access|describe-module|add-reads|add-exports|add-opens|limit-modules|patch-module))$/.test(
+ unquoted
+ );
+}
+
/** Git config values that Git later executes as commands or helper processes. */
const GIT_COMMAND_CONFIG_KEY =
/^(?:core\.(?:sshcommand|askpass|editor|pager|gitproxy|fsmonitor)|sequence\.editor|diff\.external|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|pager\.[^.]+|(?:diff|merge)tool\.[^.]+\.cmd|filter\.[^.]+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\.[^.]+\.cmd|tar\.[^.]+\.command)$/i;
@@ -2032,6 +2039,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitCommandValue = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
+ let pendingJavaOptions = false;
+ let pendingJavaSourceVersion = false;
+ let pendingJavaSourceFile = false;
+ let pendingJavaOptionValue = false;
let pendingScriptFileOperand = false;
let evalOperandAmbiguous = false;
// Static table entries keep the pending set bounded, so repeated interpreter words
@@ -2068,6 +2079,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingEnvOptionValue = false;
pendingNpmExecOptions = false;
pendingGitRebaseOptions = false;
+ pendingJavaOptions = false;
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = false;
+ pendingJavaOptionValue = false;
continue;
}
// GNU env reparses its split-string value even without an assignment or literal
@@ -2144,6 +2159,27 @@ function hasDisguisedAssignment(redacted: string): boolean {
// (`--prefix /tmp`), so tracking stays armed until a known subcommand.
}
if (pendingDenoRunScript && isAutoPublishedScriptOperand(unquoted)) return true;
+ if (pendingJavaOptionValue) {
+ pendingJavaOptionValue = false;
+ } else if (pendingJavaSourceVersion) {
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = true;
+ } else if (pendingJavaOptions || pendingJavaSourceFile) {
+ if (javaOptionTakesSeparateValue(unquoted)) {
+ pendingJavaOptionValue = true;
+ } else if (unquoted === "--source") {
+ pendingJavaSourceVersion = true;
+ } else if (unquoted.startsWith("--source=")) {
+ pendingJavaSourceFile = true;
+ } else if (pendingJavaSourceFile && !unquoted.startsWith("-")) {
+ const autoPublished = isAutoPublishedScriptOperand(unquoted);
+ pendingJavaOptions = false;
+ pendingJavaSourceFile = false;
+ if (autoPublished) return true;
+ } else if (pendingJavaOptions && !unquoted.startsWith("-")) {
+ pendingJavaOptions = false;
+ }
+ }
if (pendingDenoSubcommand) {
if (unquoted === "run") {
pendingDenoSubcommand = false;
@@ -2175,6 +2211,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
+ if (executable === "java") pendingJavaOptions = true;
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;
From bebb3785138f8977b96ebcd97520c7c663019fbd Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 00:24:21 +0000
Subject: [PATCH 081/116] fix: scan issued-token shapes linearly and keep Slack
placeholders reviewable
Unbounded prefix[class]{n,} credential regexes exhaust V8's regexp backtrack stack (RangeError) on a size-limit file that is one in-class candidate wall; glsa_, glpat-, github_pat_, xox?-, sk-, and AIza walls all reproduce, failing Preview and Push before any classification. Token formats are now declarative prefix+run shapes matched by a linear scanner used by both the hard block and the reviewable scan, fuzz-checked against the old regex semantics (200k inputs, 0 mismatches).
Slack xox?-/xapp- tokens hard-block only when the body carries a digit (issued tokens embed numeric workspace and app IDs), so placeholders like xoxb-your-token-here stay in the reviewable approval flow, matching the existing sk- rule.
---
src/node/services/backup/payload.test.ts | 57 +++++++
src/node/services/backup/payload.ts | 194 +++++++++++++++++++----
2 files changed, 218 insertions(+), 33 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 7fa180f7936..06864859ca2 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2261,6 +2261,63 @@ describe("backup payload", () => {
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("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');
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e0116ec5657..fb07c2393a4 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -67,44 +67,168 @@ function isForbiddenBasename(name: string): boolean {
function isHiddenName(name: string): boolean {
return name.startsWith(".");
}
+/** 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 CREDENTIAL_TOKEN_PATTERNS = [
+const ISSUED_TOKEN_SHAPES: readonly IssuedTokenShape[] = [
// GitHub issued prefixes: personal, OAuth, App user, installation, refresh tokens.
- /\bgh[opusr]_[A-Za-z0-9]{20,}\b/,
- /\bgithub_pat_[A-Za-z0-9_]{20,}\b/,
- /\bglsa_[A-Za-z0-9_]{20,}\b/,
- // GitLab issued prefixes: personal, deploy, runner, service-account, trigger,
- // CI job, OAuth app, feature-flag, incoming-mail, and cluster-agent tokens.
- /\bgl(?:pat|dt|rt|soat|ptt|cbt|oas|ffct|imt|agent)-[A-Za-z0-9_-]{20,}\b/,
- /\blin_api_[A-Za-z0-9]{16,}\b/,
- /\bntn_[A-Za-z0-9]{16,}\b/,
- // AWS long-term (AKIA) and temporary-session (ASIA) access-key IDs.
- /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/,
+ { 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.
- /\bx(?:ox[baprs]|app)-[A-Za-z0-9-]{10,}\b/,
+ {
+ 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.
- /\b[sr]k_live_[A-Za-z0-9]{16,}\b/,
+ { prefixes: ["sk_live_", "rk_live_"], runClass: "alnum", minRun: 16 },
// npm issued access tokens.
- /\bnpm_[A-Za-z0-9]{24,}\b/,
-] as const;
+ { 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 },
+];
+
+const PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/;
+
+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;
+}
/**
- * The digit requirement keeps documentation placeholders (`sk-your-api-key-here`) out
- * of the no-override block: issued keys are base62 and practically always carry digits,
- * while placeholders are dash-separated words. The digit-free spelling stays in the
- * reviewable scan. Checked per maximal candidate run instead of inside one regex, whose
- * digit search would backtrack quadratically across a digit-free `sk-sk-...` wall in
- * the synchronous scanner.
+ * 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 hasDigitBearingSkToken(text: string): boolean {
- for (const match of text.matchAll(/\bsk-[A-Za-z0-9_-]{16,}\b/g)) {
- if (/[0-9]/.test(match[0])) return true;
+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;
}
@@ -187,17 +311,21 @@ function stripPlaceholderRuns(text: string): string {
function matchesCredentialToken(text: string): boolean {
const scannable = stripPlaceholderRuns(text.replaceAll(EXAMPLE_ACCESS_KEY, " "));
return (
- CREDENTIAL_TOKEN_PATTERNS.some((pattern) => pattern.test(scannable)) ||
- hasDigitBearingSkToken(scannable)
+ ISSUED_TOKEN_SHAPES.some((shape) =>
+ hasIssuedToken(scannable, shape, shape.hardBlockRequiresDigit === true)
+ ) || AWS_ACCESS_KEY_ID_PATTERN.test(scannable)
);
}
-const SECRET_PATTERNS = [
- ...CREDENTIAL_TOKEN_PATTERNS,
- /\bsk-[A-Za-z0-9_-]{16,}\b/,
- /\bAIza[A-Za-z0-9_-]{35,}/,
- /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/,
-] as const;
+/** 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;
@@ -2898,7 +3026,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`.
From b3af0c6c23bae17ed8a7f9dcdd37a235e6fd054c Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 01:02:41 +0000
Subject: [PATCH 082/116] fix: restrict executable-name checks to command
positions and track find callbacks
Executable-name checks (shells, program-operand interpreters, reparse wrappers, state builtins, language/npm/git/deno/java tracking) previously fired on every word, so a portable launcher merely naming one in an argument (mcp-server --shell bash, --transport ssh) was localized and removed on a fresh-device restore. The word loop now tracks command position: separators start a new command, reserved words keep it, env's utility operand and a modeled carrier wrapper's command operand (nohup/timeout/nice/... with their leading operand counts) execute like a command start, and a carrier dash option keeps later words checked because its separate value cannot be paired. Read-redirection filenames and their descriptors are consumed without occupying command position. hasDirectAutoPublishedCommand is superseded by checking auto-published documents at every executing position, which also covers carrier- and keyword-guarded direct execution.
find/gfind at an executing position now localize when an -exec/-execdir/-ok/-okdir primary follows, since those hand their operands to execvp as a command; localizing on the primary skips modeling the terminator grammar.
---
src/node/services/backup/payload.test.ts | 41 ++++
src/node/services/backup/payload.ts | 236 +++++++++++++++--------
2 files changed, 199 insertions(+), 78 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 06864859ca2..fe78ba044d1 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1696,6 +1696,16 @@ describe("backup payload", () => {
`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"],
] as const) {
it(`localizes ${name}`, async () => {
await writeFixtureFile(
@@ -1724,6 +1734,8 @@ describe("backup payload", () => {
"env -u TOKEN ~/.xum/skills/launch.txt",
"env env /home/user/.xum/agents/launch.md",
"true; /home/user/.xum/agents/launch.md",
+ "timeout 30 ~/.xum/skills/launch.txt",
+ "nohup ~/.xum/skills/launch.txt",
]) {
await writeFixtureFile(
muxRoot,
@@ -1743,6 +1755,35 @@ describe("backup payload", () => {
}
});
+ 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",
+ ]) {
+ 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);
+ }
+ });
+
for (const [name, command] of [
["Python", "python3 ~/.xum/skills/launch.txt"],
["Node", "node /home/user/.xum/agents/launch.md"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index fb07c2393a4..86733992ea9 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1785,62 +1785,6 @@ const NPM_SUBCOMMANDS = new Set(
/** Exactly one replaced assignment, nothing else riding along in the same word. */
const CONSUMED_ASSIGNMENT = new RegExp(`^${ASSIGNMENT_NAME}${REDACTED_BACKUP_VALUE}$`);
-/**
- * A directly named auto-published document can be executable through its shebang. Track
- * command starts through assignments and active shell operators, but leave the same path
- * portable when it is merely an argument to another program.
- */
-function hasDirectAutoPublishedCommand(redacted: string): boolean {
- let commandPosition = true;
- let envWrapper = false;
- let envOptionValue = false;
- let previousEnd = 0;
- for (const match of redacted.matchAll(SHELL_WORD)) {
- const start = match.index;
- if (/[;&|()\n]/.test(redacted.slice(previousEnd, start))) {
- commandPosition = true;
- envWrapper = false;
- envOptionValue = false;
- }
- previousEnd = start + match[0].length;
- if (!commandPosition) continue;
- if (CONSUMED_ASSIGNMENT.test(match[0])) continue;
- const unquoted = unquoteShellWord(match[0]);
- if (envWrapper) {
- if (envOptionValue) {
- envOptionValue = false;
- continue;
- }
- if (unquoted === "-" || unquoted === "--") continue;
- if (envOptionTakesSeparateValue(unquoted)) {
- envOptionValue = true;
- continue;
- }
- if (unquoted.startsWith("-")) continue;
- const executable = unquoted
- .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
- .toLowerCase()
- .replace(/\.exe$/, "");
- if (executable === "env") continue;
- if (isAutoPublishedScriptOperand(unquoted)) return true;
- commandPosition = false;
- envWrapper = false;
- continue;
- }
- const executable = unquoted
- .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
- .toLowerCase()
- .replace(/\.exe$/, "");
- if (executable === "env") {
- envWrapper = true;
- continue;
- }
- if (isAutoPublishedScriptOperand(unquoted)) return true;
- commandPosition = false;
- }
- return false;
-}
-
/**
* 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
@@ -2034,6 +1978,62 @@ const SHELL_REPARSE_EXECUTABLE_NAMES = new Set([
"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",
+ "!",
+ "{",
+ "}",
+]);
+
+/**
+ * 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],
+ ["timeout", 1],
+ ["chrt", 1],
+ ["taskset", 1],
+ ["chroot", 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
@@ -2136,9 +2136,7 @@ const SHELL_STATE_WORDS = new Set([
"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). A bare `.`
- // argument (the cwd) localizes with it: keywords like `do` make command-position
- // detection undecidable here, so the dot fails closed like every ambiguous form.
+ // (`source ./launch ghp_aaa bbb` can join them into one runtime token).
"source",
".",
]);
@@ -2150,7 +2148,12 @@ const SHELL_STATE_WORDS = new Set([
* about the rest of that word.
*/
function hasDisguisedAssignment(redacted: string): boolean {
- if (hasDirectAutoPublishedCommand(redacted)) return true;
+ let commandPosition = true;
+ let envCommandExpected = false;
+ let carrierArmed = false;
+ let carrierSticky = false;
+ let carrierOperandSkips = 0;
+ let pendingFindPrimaries = false;
let envOperandsOnly = false;
let pendingPrintfVariableOption = false;
let sawEnv = false;
@@ -2182,7 +2185,29 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingScriptFileOperand = false;
evalOperandAmbiguous = false;
}
- for (const word of redacted.match(SHELL_WORD) ?? []) {
+ 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)) {
+ commandPosition = true;
+ envCommandExpected = false;
+ carrierArmed = false;
+ carrierSticky = false;
+ carrierOperandSkips = 0;
+ pendingFindPrimaries = false;
+ sawEnv = false;
+ pendingEnvOptionValue = false;
+ }
+ // 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`).
+ if (gap.includes("<")) continue;
if (CONSUMED_ASSIGNMENT.test(word)) 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
@@ -2191,6 +2216,14 @@ function hasDisguisedAssignment(redacted: string): boolean {
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) {
+ carrierOperandSkips -= 1;
+ } else {
+ carrierArmed = false;
+ executesHere = true;
}
}
if (pendingGitAliasValue) {
@@ -2319,30 +2384,43 @@ function hasDisguisedAssignment(redacted: string): boolean {
// 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 = unquoted === "printf";
+ 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`), wherever the word sits: even mid-command it still names
- // the builtin to some consumer (`env eval ...`, `bash -c 'eval ...'`). 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.
- if (SHELL_STATE_WORDS.has(unquoted)) return true;
+ // 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;
const executable = unquoted
.slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
.toLowerCase()
.replace(/\.exe$/, "");
- if (executable === "env") sawEnv = true;
- if (executable === "npm") pendingNpmSubcommand = true;
- if (executable === "git") pendingGitSubcommand = true;
- if (executable === "deno") pendingDenoSubcommand = true;
- if (executable === "java") pendingJavaOptions = true;
- 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;
+ if (executesHere) {
+ // A directly executed auto-published document runs through its shebang,
+ // publishing an executable relationship no marker can rehydrate elsewhere.
+ if (isAutoPublishedScriptOperand(unquoted)) return true;
+ if (executable === "env") sawEnv = true;
+ if (executable === "npm") pendingNpmSubcommand = true;
+ if (executable === "git") pendingGitSubcommand = true;
+ if (executable === "deno") pendingDenoSubcommand = true;
+ if (executable === "java") pendingJavaOptions = true;
+ if (FIND_EXECUTABLE_NAMES.has(executable)) pendingFindPrimaries = true;
+ const carrierSkips = COMMAND_CARRIER_OPERANDS.get(executable);
+ 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
@@ -2363,7 +2441,9 @@ function hasDisguisedAssignment(redacted: string): boolean {
}
if (attachedScriptBoundary) clearInterpreterTracking();
- const language = LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable));
+ const language = executesHere
+ ? LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable))
+ : undefined;
if (language) {
pendingLanguages.add(language);
evalOperandAmbiguous = false;
From b61ab5a227889f74c10f11e7275772d021cc494f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 01:35:50 +0000
Subject: [PATCH 083/116] fix: resolve published-document operands against the
configured root
isAutoPublishedScriptOperand matched any .xum/.mux path segment, missing documents collected from a custom (XUM_ROOT) or .xum-dev root while localizing project-local spellings like ./.xum/skills/server.txt that this backup never publishes. Operands now resolve against the configured root's spellings: the root itself, its pre-rename .mux/.xum sibling, and the ~/ shorthand for either under the home directory. Relative and foreign absolute paths stay portable; test fixtures now reference the collected temp root directly, so every localization test also covers a custom root.
Also from this round: escript joins the positional-script interpreters; git config include.path/includeIf.*.path values localize when they name a published document (the included config is read and applied); and boolean core.fsmonitor values stay portable since Git treats them as the built-in monitor toggle, not a hook pathname.
---
src/node/services/backup/payload.test.ts | 235 ++++++++++++++++++-----
src/node/services/backup/payload.ts | 127 +++++++++---
2 files changed, 293 insertions(+), 69 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index fe78ba044d1..02c98c82672 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1728,14 +1728,14 @@ describe("backup payload", () => {
it("localizes directly executed auto-published documents", async () => {
for (const command of [
- "~/.xum/skills/launch.txt",
- "MODE=fast ~/.xum/skills/launch.txt",
- "env ~/.xum/skills/launch.txt",
- "env -u TOKEN ~/.xum/skills/launch.txt",
- "env env /home/user/.xum/agents/launch.md",
- "true; /home/user/.xum/agents/launch.md",
- "timeout 30 ~/.xum/skills/launch.txt",
- "nohup ~/.xum/skills/launch.txt",
+ `${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`,
+ `true; ${muxRoot}/agents/launch.md`,
+ `timeout 30 ${muxRoot}/skills/launch.txt`,
+ `nohup ${muxRoot}/skills/launch.txt`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1784,38 +1784,176 @@ describe("backup payload", () => {
}
});
+ 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 {
+ await writeFixtureFile(
+ homeRoot,
+ "mcp.jsonc",
+ JSON.stringify({
+ servers: {
+ private: { command: `python3 ~/${path.basename(homeRoot)}/skills/launch.txt` },
+ },
+ })
+ );
+ 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 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 ~/.xum/skills/launch.txt"],
- ["Node", "node /home/user/.xum/agents/launch.md"],
- ["Rscript", "Rscript ~/.mux/memory/global/launch.markdown"],
- ["Lua", "lua5.4 ~/.xum/skills/launch.txt"],
- ["LuaJIT", "luajit /home/user/.xum/agents/launch.md"],
- ["Swift", "swift ~/.xum/skills/launch.txt"],
- ["Elixir", "elixir /home/user/.xum/agents/launch.md"],
- ["Java source mode", "java --source 17 ~/.xum/skills/launch.txt"],
- ["Java attached source mode", "java --source=17 /home/user/.xum/agents/launch.md"],
+ ["Python", "python3 /skills/launch.txt"],
+ ["Node", "node /agents/launch.md"],
+ ["Rscript", "Rscript /memory/global/launch.markdown"],
+ ["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 ~/.xum/skills/launch.txt",
+ "java --class-path libs --source 17 --module-path mods /skills/launch.txt",
],
- ["JShell", "jshell ~/.xum/skills/launch.txt"],
- ["Tcl", "tclsh ~/.xum/skills/launch.txt"],
- ["Tk wish", "wish8.6 /home/user/.xum/agents/launch.md"],
- ["Expect", "expect ~/.mux/memory/global/launch.txt"],
- ["R attached file option", "R --file=/home/alice/.xum/skills/launch.txt"],
- ["R separate file option", "R -f ~/.xum/skills/launch.txt"],
- ["PHP attached file option", "php --file=/home/alice/.xum/skills/launch.mdx"],
- ["PHP separate file option", "php -f ~/.mux/memory/global/launch.markdown"],
- ["PHP process-file option", "php -F/home/alice/.xum/skills/launch.txt"],
- ["PHP long process-file option", "php --process-file=~/.xum/skills/launch.txt"],
- ["PHP separate process-file option", "php --process-file ~/.xum/skills/launch.txt"],
- ["Deno", "deno run --config deno.json 'C:\\Users\\me\\.xum\\skills\\launch.mdx'"],
+ ["JShell", "jshell /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 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"],
+ // 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 } } })
+ JSON.stringify({ servers: { private: { command: resolved } } })
);
const payload = await createBackupPayload({
muxRoot,
@@ -1832,11 +1970,11 @@ describe("backup payload", () => {
it("localizes runtime preload modules", async () => {
for (const command of [
- "node --require /home/user/.xum/skills/launch.txt server.js",
- "node -r/home/user/.xum/skills/launch.txt server.js",
- "bun --preload /home/user/.xum/skills/launch.txt server.ts",
- "bun --require=/home/user/.xum/skills/launch.txt server.ts",
- "bun -r/home/user/.xum/skills/launch.txt server.ts",
+ `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,
@@ -1908,8 +2046,8 @@ describe("backup payload", () => {
it("localizes makefile-driven launchers", async () => {
for (const command of [
- "make -f /home/user/.xum/skills/launch.txt",
- "gmake --file=/home/user/.xum/skills/launch.txt",
+ `make -f ${muxRoot}/skills/launch.txt`,
+ `gmake --file=${muxRoot}/skills/launch.txt`,
"make --eval='run:;mcp --token ghp_Abcdef1234'",
]) {
await writeFixtureFile(
@@ -2096,8 +2234,8 @@ describe("backup payload", () => {
"python3 - -c",
"node -- --require",
"bun -- --preload",
- "R -- --file=~/.xum/skills/launch.txt",
- "php -- --file=~/.xum/skills/launch.txt",
+ `R -- --file=${muxRoot}/skills/launch.txt`,
+ `php -- --file=${muxRoot}/skills/launch.txt`,
"make -- -f",
"npm exec -- -c",
"env -- -Ssettings",
@@ -2110,7 +2248,12 @@ describe("backup payload", () => {
"Rscript server.R --port 8080",
"lua /tmp/server.lua",
"luajit /tmp/server.lua",
- "mcp-server --config ~/.xum/skills/launch.txt",
+ `mcp-server --config ${muxRoot}/skills/launch.txt`,
+ // Project-local and relative spellings resolve against the server's own
+ // working directory, never the collected root.
+ "./.xum/skills/server.txt --port 8080",
+ "mcp-server ./.xum/skills/server.txt",
+ "python3 /repo/.xum/skills/launch.txt",
"swift /tmp/launch.swift",
"elixir /tmp/launch.exs",
"iex /tmp/launch.exs",
@@ -2119,11 +2262,11 @@ describe("backup payload", () => {
"tclsh /tmp/server.tcl",
"wish8.6 /tmp/app.tcl",
"expect /tmp/session.exp",
- "R --file=/tmp/server.R ~/.xum/skills/argument.txt",
- "R -f /tmp/server.R ~/.xum/skills/argument.txt",
- "php --file=/tmp/server.php ~/.xum/skills/argument.txt",
- "php -F/tmp/process.php ~/.xum/skills/argument.txt",
- "php --process-file /tmp/process.php ~/.xum/skills/argument.txt",
+ `R --file=/tmp/server.R ${muxRoot}/skills/argument.txt`,
+ `R -f /tmp/server.R ${muxRoot}/skills/argument.txt`,
+ `php --file=/tmp/server.php ${muxRoot}/skills/argument.txt`,
+ `php -F/tmp/process.php ${muxRoot}/skills/argument.txt`,
+ `php --process-file /tmp/process.php ${muxRoot}/skills/argument.txt`,
"npx notes-mcp --port 8080",
"npm exec notes-mcp -- --port 8080",
"npm --prefix /tmp install exec -c",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 86733992ea9..393b8cfd6d4 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 * 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 {
@@ -1755,7 +1756,18 @@ function javaOptionTakesSeparateValue(unquoted: string): boolean {
/** Git config values that Git later executes as commands or helper processes. */
const GIT_COMMAND_CONFIG_KEY =
- /^(?:core\.(?:sshcommand|askpass|editor|pager|gitproxy|fsmonitor)|sequence\.editor|diff\.external|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|pager\.[^.]+|(?:diff|merge)tool\.[^.]+\.cmd|filter\.[^.]+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\.[^.]+\.cmd|tar\.[^.]+\.command)$/i;
+ /^(?:core\.(?:sshcommand|askpass|editor|pager|gitproxy)|sequence\.editor|diff\.external|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|pager\.[^.]+|(?:diff|merge)tool\.[^.]+\.cmd|filter\.[^.]+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\.[^.]+\.cmd|tar\.[^.]+\.command)$/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;
/**
* Documentation is the only thing a recursive collection publishes without asking.
@@ -1764,15 +1776,64 @@ const GIT_COMMAND_CONFIG_KEY =
*/
const AUTO_PUBLISHED_RECURSIVE_FILE = /\.(?:md|mdx|markdown|txt)$/i;
-function isAutoPublishedScriptOperand(unquoted: string): boolean {
- const normalized = unquoted.replaceAll("\\", "/");
- const relative = /(?:^|\/)\.(?:xum|mux)\/(.+)$/i.exec(normalized)?.[1];
- if (relative === undefined) return false;
- if (/^AGENTS\.md$/i.test(relative)) return true;
- if (/^agents\/[^/]+\.md$/i.test(relative)) return true;
- return (
- /^(?:skills|memory\/global)\//i.test(relative) && AUTO_PUBLISHED_RECURSIVE_FILE.test(relative)
- );
+/** Lowercased forward-slash spelling without trailing separators, for prefix compares. */
+function normalizeRootPrefix(root: string): string {
+ return root.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();
+ const root = normalizeRootPrefix(muxRoot);
+ if (root !== "") {
+ 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 = normalizeRootPrefix(os.homedir());
+ if (home !== "") {
+ for (const candidate of absolute) {
+ if (candidate.startsWith(`${home}/`)) prefixes.add(`~${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 = unquoted.replaceAll("\\", "/").toLowerCase();
+ 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;
}
/** Known npm commands and aliases terminate global-option parsing. */
@@ -2075,7 +2136,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^(?:elixir|iex)[0-9.]*$/, evalWord: /^(?:-e$|--eval(?:=|$)|--rpc-eval(?:=|$))/ },
// These launchers execute a positional script but need no inline-eval matcher here;
// auto-published script operands still localize through the shared check.
- { name: /^(?:jshell|swift|tclsh|wish|expectk?|jimsh)[0-9.]*$/ },
+ { name: /^(?:jshell|swift|tclsh|wish|expectk?|jimsh|escript)[0-9.]*$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
@@ -2147,7 +2208,7 @@ const SHELL_STATE_WORDS = new Set([
* 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): boolean {
+function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[]): boolean {
let commandPosition = true;
let envCommandExpected = false;
let carrierArmed = false;
@@ -2168,6 +2229,8 @@ function hasDisguisedAssignment(redacted: string): boolean {
let pendingGitConfigOptionValue = false;
let pendingGitAliasValue = false;
let pendingGitCommandValue = false;
+ let pendingGitFsmonitorValue = false;
+ let pendingGitIncludePathValue = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
let pendingJavaOptions = false;
@@ -2225,7 +2288,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
continue;
}
if (pendingScriptFileOperand) {
- const autoPublished = isAutoPublishedScriptOperand(unquoted);
+ const autoPublished = isAutoPublishedScriptOperand(unquoted, rootPrefixes);
clearInterpreterTracking();
if (autoPublished) return true;
}
@@ -2300,6 +2363,16 @@ function hasDisguisedAssignment(redacted: string): boolean {
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;
@@ -2309,6 +2382,10 @@ function hasDisguisedAssignment(redacted: string): boolean {
pendingGitConfigKey = false;
if (/^alias\.[^.]+$/i.test(unquoted)) {
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;
}
@@ -2351,7 +2428,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// Anything else can be a separated value for a global config option
// (`--prefix /tmp`), so tracking stays armed until a known subcommand.
}
- if (pendingDenoRunScript && isAutoPublishedScriptOperand(unquoted)) return true;
+ if (pendingDenoRunScript && isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
if (pendingJavaOptionValue) {
pendingJavaOptionValue = false;
} else if (pendingJavaSourceVersion) {
@@ -2365,7 +2442,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
} else if (unquoted.startsWith("--source=")) {
pendingJavaSourceFile = true;
} else if (pendingJavaSourceFile && !unquoted.startsWith("-")) {
- const autoPublished = isAutoPublishedScriptOperand(unquoted);
+ const autoPublished = isAutoPublishedScriptOperand(unquoted, rootPrefixes);
pendingJavaOptions = false;
pendingJavaSourceFile = false;
if (autoPublished) return true;
@@ -2405,7 +2482,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
if (executesHere) {
// A directly executed auto-published document runs through its shebang,
// publishing an executable relationship no marker can rehydrate elsewhere.
- if (isAutoPublishedScriptOperand(unquoted)) return true;
+ if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
@@ -2429,7 +2506,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
for (const pending of pendingLanguages) {
const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
if (script !== undefined) {
- if (isAutoPublishedScriptOperand(script)) return true;
+ if (isAutoPublishedScriptOperand(script, rootPrefixes)) return true;
attachedScriptBoundary = true;
break;
}
@@ -2453,7 +2530,7 @@ function hasDisguisedAssignment(redacted: string): boolean {
// (`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 (isAutoPublishedScriptOperand(unquoted)) {
+ } else if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) {
// 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.
@@ -2775,7 +2852,7 @@ export const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
*/
export const MAX_TOTAL_ANALYZED_COMMAND_LENGTH = 8 * MAX_ANALYZED_COMMAND_LENGTH;
-function redactCommandEnvAssignments(command: string): string {
+function redactCommandEnvAssignments(command: string, rootPrefixes: readonly string[]): 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.
@@ -2795,7 +2872,7 @@ function redactCommandEnvAssignments(command: string): string {
const constructs = findActiveShellConstructs(analyzed);
if (
UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
- hasDisguisedAssignment(redactedCode) ||
+ hasDisguisedAssignment(redactedCode, rootPrefixes) ||
constructs.carrier ||
constructs.heredoc ||
constructs.processSubstitution ||
@@ -2838,10 +2915,14 @@ function isBashStartupHookVariable(name: string, value: unknown): boolean {
return name === "BASH_ENV" && value !== "" && value !== undefined;
}
-function redactMcpConfig(content: Buffer): {
+function redactMcpConfig(
+ content: Buffer,
+ muxRoot: string
+): {
content: Buffer;
redactionPaths: BackupRedactionPath[];
} {
+ const rootPrefixes = collectedDocumentRootPrefixes(muxRoot);
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
@@ -2867,7 +2948,7 @@ function redactMcpConfig(content: Buffer): {
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
- redacted = redactCommandEnvAssignments(command);
+ redacted = redactCommandEnvAssignments(command, rootPrefixes);
}
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
@@ -3144,7 +3225,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);
}
From 94ebaee38ff2ae0be72598c55f90f01c0b2965d8 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 03:51:45 +0000
Subject: [PATCH 084/116] fix: cover stdin-redirected documents,
coproc/function bodies, and resource wrappers
A published document redirected into an interpreter (node < launch.txt) was skipped with the redirection filename, so that spelling now localizes like a positional script operand. coproc and function join the command-position keywords: coproc executes its command and a function body executes at its call site, with the optional/required NAME before a compound body consumed without occupying command position. prlimit and setpriv (plus the same-shape numactl, eatmydata, runcon) join the carrier table so their program operands are checked like command starts; setarch's leading arch operand is optional, so it checks every following word instead of a fixed skip count.
The reviewable private-key pattern now matches any PEM qualifier and PGP's BLOCK suffix (BEGIN ENCRYPTED/DSA PRIVATE KEY, PGP PRIVATE KEY BLOCK), since OpenSSL emits qualifiers the previous RSA/EC/OPENSSH list missed.
---
src/node/services/backup/payload.test.ts | 33 +++++++++++++++
src/node/services/backup/payload.ts | 51 +++++++++++++++++++++---
2 files changed, 79 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 02c98c82672..afcb24ed3c5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1706,6 +1706,14 @@ describe("backup payload", () => {
["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"],
] as const) {
it(`localizes ${name}`, async () => {
await writeFixtureFile(
@@ -1736,6 +1744,11 @@ describe("backup payload", () => {
`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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1765,6 +1778,8 @@ describe("backup payload", () => {
"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",
]) {
await writeFixtureFile(
muxRoot,
@@ -2490,6 +2505,24 @@ describe("backup payload", () => {
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, "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);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 393b8cfd6d4..603d57ea844 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -149,7 +149,9 @@ const REVIEW_ONLY_TOKEN_SHAPES: readonly IssuedTokenShape[] = [
{ prefixes: ["AIza"], runClass: "word-dash", minRun: 35, openEnded: true },
];
-const PRIVATE_KEY_PATTERN = /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/;
+// 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;
@@ -2057,6 +2059,11 @@ const SHELL_COMMAND_KEYWORDS = new Set([
"!",
"{",
"}",
+ // 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",
]);
/**
@@ -2081,10 +2088,18 @@ const COMMAND_CARRIER_OPERANDS = new Map([
["command", 0],
["builtin", 0],
["exec", 0],
+ ["prlimit", 0],
+ ["setpriv", 0],
+ ["numactl", 0],
+ ["eatmydata", 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],
]);
/**
@@ -2215,6 +2230,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let carrierSticky = false;
let carrierOperandSkips = 0;
let pendingFindPrimaries = false;
+ let pendingBodyName: "coproc" | "function" | null = null;
let envOperandsOnly = false;
let pendingPrintfVariableOption = false;
let sawEnv = false;
@@ -2265,12 +2281,18 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
carrierSticky = false;
carrierOperandSkips = 0;
pendingFindPrimaries = false;
+ pendingBodyName = null;
sawEnv = false;
pendingEnvOptionValue = false;
}
// 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`).
- if (gap.includes("<")) continue;
+ // 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 (isAutoPublishedScriptOperand(unquoteShellWord(word), rootPrefixes)) return true;
+ continue;
+ }
if (CONSUMED_ASSIGNMENT.test(word)) 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
@@ -2314,8 +2336,25 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaOptionValue = 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)) continue;
+ 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
@@ -2490,7 +2529,9 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
if (executable === "java") pendingJavaOptions = true;
if (FIND_EXECUTABLE_NAMES.has(executable)) pendingFindPrimaries = true;
const carrierSkips = COMMAND_CARRIER_OPERANDS.get(executable);
- if (carrierSkips !== undefined) {
+ if (carrierSkips === -1) {
+ carrierSticky = true;
+ } else if (carrierSkips !== undefined) {
carrierArmed = true;
carrierOperandSkips = carrierSkips;
}
From 4721d66bf243ebcc95f9cbdc7a91122a6fccd9d0 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:05:46 +0000
Subject: [PATCH 085/116] fix: track systemd executor command operands
systemd-run was absent from the carrier table, so its command operand was never checked and systemd-run --user --scope /skills/launch.txt could publish an executable relationship with a collected document. systemd-run, systemd-inhibit, and systemd-cat share the [OPTIONS...] COMMAND grammar and join the carriers; a dash option makes the walk sticky, which also covers their separate-value option spellings, while the names stay portable in argument positions.
---
src/node/services/backup/payload.test.ts | 5 +++++
src/node/services/backup/payload.ts | 5 +++++
2 files changed, 10 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index afcb24ed3c5..ba435274d74 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1714,6 +1714,9 @@ describe("backup payload", () => {
["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"],
] as const) {
it(`localizes ${name}`, async () => {
await writeFixtureFile(
@@ -1749,6 +1752,7 @@ describe("backup payload", () => {
// 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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1780,6 +1784,7 @@ describe("backup payload", () => {
"mcp-server --mode find -exec /tmp/plugin",
"mcp-server --wrap prlimit --mode coproc",
"mcp-server < /tmp/input.json",
+ "mcp-server --launcher systemd-run",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 603d57ea844..78b8bc8e1f2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2092,6 +2092,11 @@ const COMMAND_CARRIER_OPERANDS = new Map([
["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],
From 6323869eba085aa1a8f179178c378411d07e09cf Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:10:59 +0000
Subject: [PATCH 086/116] fix: check CMake and CTest operands
cmake -P script mode executed a published document while the path was treated as an ordinary argument. CMake executes several operand grammars (-P scripts, -E env/chdir/time command mode) and CTest runs -S/-SP dashboard scripts, so both join the check-every-operand executors rather than modeling each option, failing closed like setarch. Build-tree spellings such as cmake --build build stay portable.
---
src/node/services/backup/payload.test.ts | 4 ++++
src/node/services/backup/payload.ts | 5 +++++
2 files changed, 9 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ba435274d74..25895862691 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1717,6 +1717,7 @@ describe("backup payload", () => {
["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(
@@ -1753,6 +1754,8 @@ describe("backup payload", () => {
`node < ${muxRoot}/skills/launch.txt`,
`sh 0< ${muxRoot}/skills/launch.txt`,
`systemd-run --user --scope ${muxRoot}/skills/launch.txt`,
+ `cmake -P ${muxRoot}/skills/launch.txt`,
+ `ctest -S ${muxRoot}/skills/launch.txt`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1785,6 +1788,7 @@ describe("backup payload", () => {
"mcp-server --wrap prlimit --mode coproc",
"mcp-server < /tmp/input.json",
"mcp-server --launcher systemd-run",
+ "cmake --build build --target package",
]) {
await writeFixtureFile(
muxRoot,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 78b8bc8e1f2..42ce82d1d28 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2105,6 +2105,11 @@ const COMMAND_CARRIER_OPERANDS = new Map([
// -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],
+ // 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],
]);
/**
From 373125f43c501fafe7ced5e1e1cd901af7cbf7c6 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:26:02 +0000
Subject: [PATCH 087/116] fix: reset parser state at separators, normalize
operand paths, end deno tracking at the entrypoint
A control operator starts a new command, so the separator reset now clears all per-command parser state (interpreter tracking, git/npm/deno/java pendings, printf and env latches): retained Python tracking previously read python3 --version && mcp-server -c config as evaluation and removed the portable server on restore.
Published-document operands and root prefixes are compared after lexical path normalization (path.posix.normalize plus case and separator folding), so redundant separators and dot segments (//skills/x, /./skills/x) name the same collected file; lexical .. collapse can only over-localize across symlinks, failing closed.
deno run's entrypoint now ends script tracking: later words are the program's arguments, so a published path among them is data (deno run /opt/server.ts /skills/config.txt stays portable). A dash option makes the entrypoint ambiguous and tracking stays armed, matching the interpreter boundary.
---
src/node/services/backup/payload.test.ts | 8 ++++
src/node/services/backup/payload.ts | 57 +++++++++++++++++++++---
2 files changed, 58 insertions(+), 7 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 25895862691..434dd1b1c10 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1756,6 +1756,9 @@ describe("backup payload", () => {
`systemd-run --user --scope ${muxRoot}/skills/launch.txt`,
`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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1789,6 +1792,10 @@ describe("backup payload", () => {
"mcp-server < /tmp/input.json",
"mcp-server --launcher systemd-run",
"cmake --build build --target package",
+ // 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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1967,6 +1974,7 @@ describe("backup payload", () => {
["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) {
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 42ce82d1d28..b4851e3211f 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1778,9 +1778,14 @@ const GIT_INCLUDE_PATH_CONFIG_KEY = /^include(?:if\..+)?\.path$/i;
*/
const AUTO_PUBLISHED_RECURSIVE_FILE = /\.(?:md|mdx|markdown|txt)$/i;
-/** Lowercased forward-slash spelling without trailing separators, for prefix compares. */
-function normalizeRootPrefix(root: string): string {
- return root.replaceAll("\\", "/").replace(/\/+$/, "").toLowerCase();
+/**
+ * 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();
}
/**
@@ -1793,7 +1798,7 @@ function normalizeRootPrefix(root: string): string {
*/
function collectedDocumentRootPrefixes(muxRoot: string): string[] {
const absolute = new Set();
- const root = normalizeRootPrefix(muxRoot);
+ const root = normalizeComparablePath(muxRoot);
if (root !== "") {
absolute.add(root);
const basename = root.slice(root.lastIndexOf("/") + 1);
@@ -1805,7 +1810,7 @@ function collectedDocumentRootPrefixes(muxRoot: string): string[] {
if (renamed !== null) absolute.add(root.slice(0, root.length - basename.length) + renamed);
}
const prefixes = new Set(absolute);
- const home = normalizeRootPrefix(os.homedir());
+ const home = normalizeComparablePath(os.homedir());
if (home !== "") {
for (const candidate of absolute) {
if (candidate.startsWith(`${home}/`)) prefixes.add(`~${candidate.slice(home.length)}`);
@@ -1822,7 +1827,7 @@ function collectedDocumentRootPrefixes(muxRoot: string): string[] {
* only remove a portable launcher on a fresh-device restore.
*/
function isAutoPublishedScriptOperand(unquoted: string, rootPrefixes: readonly string[]): boolean {
- const normalized = unquoted.replaceAll("\\", "/").toLowerCase();
+ const normalized = normalizeComparablePath(unquoted);
for (const prefix of rootPrefixes) {
if (!normalized.startsWith(`${prefix}/`)) continue;
const relative = normalized.slice(prefix.length + 1);
@@ -2259,6 +2264,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let pendingGitIncludePathValue = false;
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
+ let pendingDenoRunAmbiguous = false;
let pendingJavaOptions = false;
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
@@ -2285,6 +2291,9 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// 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;
envCommandExpected = false;
carrierArmed = false;
@@ -2294,6 +2303,28 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingBodyName = null;
sawEnv = false;
pendingEnvOptionValue = false;
+ envOperandsOnly = false;
+ pendingPrintfVariableOption = false;
+ pendingNpmSubcommand = false;
+ pendingNpmExecOptions = false;
+ pendingGitSubcommand = false;
+ pendingGitOptionValue = false;
+ pendingGitSubmoduleAction = false;
+ pendingGitRebaseOptions = false;
+ pendingGitConfigKey = false;
+ pendingGitConfigOptionValue = false;
+ pendingGitAliasValue = false;
+ pendingGitCommandValue = false;
+ pendingGitFsmonitorValue = false;
+ pendingGitIncludePathValue = false;
+ pendingDenoSubcommand = false;
+ pendingDenoRunScript = false;
+ pendingDenoRunAmbiguous = false;
+ pendingJavaOptions = false;
+ pendingJavaSourceVersion = false;
+ pendingJavaSourceFile = false;
+ pendingJavaOptionValue = 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
@@ -2477,7 +2508,19 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// Anything else can be a separated value for a global config option
// (`--prefix /tmp`), so tracking stays armed until a known subcommand.
}
- if (pendingDenoRunScript && isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
+ if (pendingDenoRunScript) {
+ if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) 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 (pendingJavaOptionValue) {
pendingJavaOptionValue = false;
} else if (pendingJavaSourceVersion) {
From 431e16cb64b1afae15ba23ddeb3b2dfefe164fc3 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:42:41 +0000
Subject: [PATCH 088/116] fix: localize Java argument files and sweep
command-valued Git config keys
The Java launcher expands @argument-files into options before parsing, so a published args file can inject --source and a script operand: a published @-file now localizes, and any other @-file leaves option tracking armed because its expansion is not visible to this scan.
merge..driver was missing from the command-valued Git config keys; sweeping git help --config for the same shape also adds diff..command/textconv, hook..command, guitool/browser cmd and path, core.alternateRefsCommand, gpg.ssh.defaultKeyCommand, sendemail sendmail/cc/to commands, and uploadpack.packObjectsHook. Driver, tool, and hook names are user-chosen subsections that may contain dots, so those middles match greedily. Two-segment keys like merge.conflictstyle stay portable.
---
src/node/services/backup/payload.test.ts | 9 +++++++++
src/node/services/backup/payload.ts | 16 +++++++++++++---
2 files changed, 22 insertions(+), 3 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 434dd1b1c10..724ec1082a8 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1759,6 +1759,9 @@ describe("backup payload", () => {
// 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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1796,6 +1799,9 @@ describe("backup payload", () => {
"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`,
+ // Two-segment merge/diff keys hold settings, not driver commands.
+ "git config merge.conflictstyle diff3",
+ "java @/tmp/opts.txt Main --port 8080",
]) {
await writeFixtureFile(
muxRoot,
@@ -2033,6 +2039,9 @@ describe("backup payload", () => {
"git config core.sshCommand 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno567890123456'; git fetch origin",
"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,
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index b4851e3211f..509d9751571 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1756,9 +1756,13 @@ function javaOptionTakesSeparateValue(unquoted: string): boolean {
);
}
-/** Git config values that Git later executes as commands or helper processes. */
+/**
+ * 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)|sequence\.editor|diff\.external|interactive\.difffilter|gpg(?:\.[^.]+)?\.program|pager\.[^.]+|(?:diff|merge)tool\.[^.]+\.cmd|filter\.[^.]+\.(?:clean|smudge|process)|credential(?:\..+)?\.helper|man\.[^.]+\.cmd|tar\.[^.]+\.command)$/i;
+ /^(?: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)$/i;
/**
* core.fsmonitor doubles as a boolean toggle for the built-in monitor; only a
@@ -2527,7 +2531,13 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaSourceVersion = false;
pendingJavaSourceFile = true;
} else if (pendingJavaOptions || pendingJavaSourceFile) {
- if (javaOptionTakesSeparateValue(unquoted)) {
+ 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 (isAutoPublishedScriptOperand(unquoted.slice(1), rootPrefixes)) return true;
+ } else if (javaOptionTakesSeparateValue(unquoted)) {
pendingJavaOptionValue = true;
} else if (unquoted === "--source") {
pendingJavaSourceVersion = true;
From f3c88b0408f228c2a9ada56d0a4843d7349d7a1f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 04:46:59 +0000
Subject: [PATCH 089/116] fix: match the current user's named-home spelling of
the settings root
Bash expands ~user like ~ for the current user, so python3 ~alice/.xum/skills/launch.txt executed a collected document the prefix builder missed. Home-prefixed root candidates now also add the ~username spelling; userInfo can throw on systems without a passwd entry, where the bare ~ spelling still covers the common case.
---
src/node/services/backup/payload.test.ts | 40 +++++++++++++-----------
src/node/services/backup/payload.ts | 13 +++++++-
2 files changed, 33 insertions(+), 20 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 724ec1082a8..bee095c8da1 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1824,25 +1824,27 @@ describe("backup payload", () => {
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 {
- await writeFixtureFile(
- homeRoot,
- "mcp.jsonc",
- JSON.stringify({
- servers: {
- private: { command: `python3 ~/${path.basename(homeRoot)}/skills/launch.txt` },
- },
- })
- );
- 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);
+ // 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`,
+ ]) {
+ 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 });
}
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 509d9751571..e49beb79efb 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1815,9 +1815,20 @@ function collectedDocumentRootPrefixes(muxRoot: string): string[] {
}
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}/`)) prefixes.add(`~${candidate.slice(home.length)}`);
+ if (!candidate.startsWith(`${home}/`)) continue;
+ prefixes.add(`~${candidate.slice(home.length)}`);
+ if (username !== "") prefixes.add(`~${username}${candidate.slice(home.length)}`);
}
}
return [...prefixes];
From 138ef24715813bab07e4ed86848939ffc14fa8ee Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:04:23 +0000
Subject: [PATCH 090/116] fix: localize JShell startup files that name
auto-published documents
---
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 16 +++++++++++++++-
2 files changed, 18 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index bee095c8da1..4e63d80ac2d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1802,6 +1802,7 @@ describe("backup payload", () => {
// 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",
]) {
await writeFixtureFile(
muxRoot,
@@ -1972,6 +1973,8 @@ describe("backup payload", () => {
"java --class-path libs --source 17 --module-path mods /skills/launch.txt",
],
["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"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index e49beb79efb..5a0a2b43cbe 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2155,6 +2155,15 @@ interface LanguageInterpreter {
evalWord?: RegExp;
attachedScriptFile?: RegExp;
separateScriptFileOption?: RegExp;
+ /**
+ * Attached option naming an auxiliary file the interpreter executes before its
+ * main operands (jshell --startup=FILE). Unlike attachedScriptFile it is not a
+ * script boundary: a positional load file can still follow, so tracking stays
+ * armed and the option word keeps its ordinary ambiguous-dash handling. The
+ * separate spelling needs no matcher, because after any dash option a published
+ * operand already localizes through the armed tracking.
+ */
+ attachedStartupFile?: RegExp;
}
const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
@@ -2181,7 +2190,8 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^(?:elixir|iex)[0-9.]*$/, evalWord: /^(?:-e$|--eval(?:=|$)|--rpc-eval(?:=|$))/ },
// These launchers execute a positional script but need no inline-eval matcher here;
// auto-published script operands still localize through the shared check.
- { name: /^(?:jshell|swift|tclsh|wish|expectk?|jimsh|escript)[0-9.]*$/ },
+ { name: /^(?:swift|tclsh|wish|expectk?|jimsh|escript)[0-9.]*$/ },
+ { name: /^jshell[0-9.]*$/, attachedStartupFile: /^--startup=(.+)$/ },
{
name: /^r$/,
evalWord: /^(?:-e$|--expression(?:=|$))/,
@@ -2619,6 +2629,10 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// for code; an automatically published script localizes first.
let attachedScriptBoundary = false;
for (const pending of pendingLanguages) {
+ const startup = pending.attachedStartupFile?.exec(unquoted)?.[1];
+ if (startup !== undefined && isAutoPublishedScriptOperand(startup, rootPrefixes)) {
+ return true;
+ }
const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
if (script !== undefined) {
if (isAutoPublishedScriptOperand(script, rootPrefixes)) return true;
From 5e1586a5519e69a233513c2d76dc3a62dcc28a2e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:23:19 +0000
Subject: [PATCH 091/116] fix: localize hash -p remapping to published files
and treat alias as shell state
---
reply75.md | 1 +
src/node/services/backup/payload.test.ts | 9 +++++++++
src/node/services/backup/payload.ts | 24 ++++++++++++++++++++++++
3 files changed, 34 insertions(+)
create mode 100644 reply75.md
diff --git a/reply75.md b/reply75.md
new file mode 100644
index 00000000000..991746f6a23
--- /dev/null
+++ b/reply75.md
@@ -0,0 +1 @@
+Fixed in 138ef24715813bab07e4ed86848939ffc14fa8ee. `LanguageInterpreter` gained `attachedStartupFile`, and JShell moved to its own row with a `--startup=(.+)` matcher. A startup file naming an auto-published document now localizes the config. Unlike `attachedScriptFile` it is not treated as a script boundary, because the startup file runs before the main operands and a positional load file can still follow, so tracking stays armed. The separate spelling (`--startup `) needs no dedicated matcher: after any dash option a published operand already localizes through the armed tracking, and a test pins that. Added tests: attached and separate startup spellings naming `/skills/launch.txt` localize, and `jshell --startup=/tmp/snippets.jsh /tmp/main.jsh` stays portable. Red toggle (removing only the `attachedStartupFile` matcher) fails exactly the new attached-startup test.
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 4e63d80ac2d..781fa899c9d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1803,6 +1803,8 @@ describe("backup payload", () => {
"git config merge.conflictstyle diff3",
"java @/tmp/opts.txt Main --port 8080",
"jshell --startup=/tmp/snippets.jsh /tmp/main.jsh",
+ "hash -p /tmp/wrapper.sh mcp-server; mcp-server",
+ "hash -r; mcp-server",
]) {
await writeFixtureFile(
muxRoot,
@@ -1975,6 +1977,10 @@ describe("backup payload", () => {
["JShell", "jshell /skills/launch.txt"],
["JShell attached startup file", "jshell --startup=/skills/launch.txt"],
["JShell separate startup file", "jshell --startup /skills/launch.txt"],
+ // `hash -p` binds a command name to the file, which runs on the name's next use.
+ ["Bash hash separate remapping", "hash -p /skills/launch.txt launch; launch"],
+ ["Bash hash attached remapping", "hash -p/agents/launch.md launch"],
+ ["Bash hash clustered remapping", "hash -rp /skills/launch.txt launch"],
["Tcl", "tclsh /skills/launch.txt"],
["Tk wish", "wish8.6 /agents/launch.md"],
["Expect", "expect /memory/global/launch.txt"],
@@ -2183,6 +2189,9 @@ describe("backup payload", () => {
"read TOKEN <./config.txt; mcp",
// `shopt -so allexport` flips the same allexport state `set -a` does.
"shopt -so allexport; printf -v TOKEN %s%s ghp_aaaaaaaaaa bbbbbbbbbb; mcp",
+ // `alias` rebinds later command words; POSIX shells expand aliases in
+ // non-interactive scripts, so the scan cannot follow what a later word runs.
+ "alias mcp='mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'; mcp",
// `source` and `.` run a file in this shell with fragments as positionals.
"source ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
". ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 5a0a2b43cbe..057d12aa92c 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2246,6 +2246,9 @@ const SHELL_STATE_WORDS = new 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",
// 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`).
@@ -2294,6 +2297,8 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
+ let pendingHashOptions = false;
+ let pendingHashPathValue = false;
let pendingScriptFileOperand = false;
let evalOperandAmbiguous = false;
// Static table entries keep the pending set bounded, so repeated interpreter words
@@ -2349,6 +2354,8 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
+ pendingHashOptions = false;
+ pendingHashPathValue = false;
clearInterpreterTracking();
}
// The word after `<` is a read redirection's filename, never a command or an
@@ -2581,6 +2588,21 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingDenoSubcommand = false;
}
}
+ if (pendingHashPathValue) {
+ pendingHashPathValue = false;
+ if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
+ } else if (pendingHashOptions) {
+ // `hash -p FILE NAME` binds NAME to FILE, which then runs on the name's next
+ // use, so a published file localizes. The value may be attached to the flag
+ // cluster or arrive as the next word; scanning past bash's option terminator
+ // or its first name operand only fails closed.
+ const remapped = /^-[dlrt]*p(.*)$/.exec(unquoted)?.[1];
+ if (remapped === "") {
+ pendingHashPathValue = true;
+ } else if (remapped !== undefined && isAutoPublishedScriptOperand(remapped, rootPrefixes)) {
+ 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;
@@ -2611,6 +2633,8 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
if (executable === "java") pendingJavaOptions = true;
+ // A builtin, so matched on the quote-removed word like the state words above.
+ if (unquoted === "hash") pendingHashOptions = true;
if (FIND_EXECUTABLE_NAMES.has(executable)) pendingFindPrimaries = true;
const carrierSkips = COMMAND_CARRIER_OPERANDS.get(executable);
if (carrierSkips === -1) {
From 16763b53ccd495523b7696bdd244ca2c646b92f6 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:43:35 +0000
Subject: [PATCH 092/116] chore: remove a stray review reply artifact from the
repository root
---
reply75.md | 1 -
1 file changed, 1 deletion(-)
delete mode 100644 reply75.md
diff --git a/reply75.md b/reply75.md
deleted file mode 100644
index 991746f6a23..00000000000
--- a/reply75.md
+++ /dev/null
@@ -1 +0,0 @@
-Fixed in 138ef24715813bab07e4ed86848939ffc14fa8ee. `LanguageInterpreter` gained `attachedStartupFile`, and JShell moved to its own row with a `--startup=(.+)` matcher. A startup file naming an auto-published document now localizes the config. Unlike `attachedScriptFile` it is not treated as a script boundary, because the startup file runs before the main operands and a positional load file can still follow, so tracking stays armed. The separate spelling (`--startup `) needs no dedicated matcher: after any dash option a published operand already localizes through the armed tracking, and a test pins that. Added tests: attached and separate startup spellings naming `/skills/launch.txt` localize, and `jshell --startup=/tmp/snippets.jsh /tmp/main.jsh` stays portable. Red toggle (removing only the `attachedStartupFile` matcher) fails exactly the new attached-startup test.
From 518a5f3b26a84824d0d55e3e8a53d70f54e7c31d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 05:43:36 +0000
Subject: [PATCH 093/116] fix: localize every hash -p remap and the enable
builtin loader
---
src/node/services/backup/payload.test.ts | 13 +++++++-----
src/node/services/backup/payload.ts | 27 +++++++++---------------
2 files changed, 18 insertions(+), 22 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 781fa899c9d..c4f8f97dbb9 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1803,7 +1803,6 @@ describe("backup payload", () => {
"git config merge.conflictstyle diff3",
"java @/tmp/opts.txt Main --port 8080",
"jshell --startup=/tmp/snippets.jsh /tmp/main.jsh",
- "hash -p /tmp/wrapper.sh mcp-server; mcp-server",
"hash -r; mcp-server",
]) {
await writeFixtureFile(
@@ -1977,10 +1976,6 @@ describe("backup payload", () => {
["JShell", "jshell /skills/launch.txt"],
["JShell attached startup file", "jshell --startup=/skills/launch.txt"],
["JShell separate startup file", "jshell --startup /skills/launch.txt"],
- // `hash -p` binds a command name to the file, which runs on the name's next use.
- ["Bash hash separate remapping", "hash -p /skills/launch.txt launch; launch"],
- ["Bash hash attached remapping", "hash -p/agents/launch.md launch"],
- ["Bash hash clustered remapping", "hash -rp /skills/launch.txt launch"],
["Tcl", "tclsh /skills/launch.txt"],
["Tk wish", "wish8.6 /agents/launch.md"],
["Expect", "expect /memory/global/launch.txt"],
@@ -2192,6 +2187,14 @@ describe("backup payload", () => {
// `alias` rebinds later command words; POSIX shells expand aliases in
// non-interactive scripts, so the scan cannot follow what a later word runs.
"alias mcp='mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'; mcp",
+ // `hash -p` binds a command name to any full pathname, so the remapped word
+ // can hand its arguments to an installed evaluator on the name's next use.
+ "hash -p /usr/bin/python3 launch; launch -c 'x'",
+ "hash -p/usr/bin/python3 launch",
+ "hash -rp /usr/bin/python3 launch",
+ // `enable -f` dlopens any file as a builtin; `-n` remaps a builtin word to a
+ // PATH program. Either changes what later words execute.
+ "enable -f ./module.txt leak; leak",
// `source` and `.` run a file in this shell with fragments as positionals.
"source ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
". ./launch ghp_aaaaaaaaaa bbbbbbbbbb",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 057d12aa92c..acf264b2415 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2249,6 +2249,10 @@ const SHELL_STATE_WORDS = new Set([
// `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`).
@@ -2298,7 +2302,6 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
let pendingHashOptions = false;
- let pendingHashPathValue = false;
let pendingScriptFileOperand = false;
let evalOperandAmbiguous = false;
// Static table entries keep the pending set bounded, so repeated interpreter words
@@ -2355,7 +2358,6 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
pendingHashOptions = false;
- pendingHashPathValue = false;
clearInterpreterTracking();
}
// The word after `<` is a read redirection's filename, never a command or an
@@ -2588,21 +2590,12 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingDenoSubcommand = false;
}
}
- if (pendingHashPathValue) {
- pendingHashPathValue = false;
- if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
- } else if (pendingHashOptions) {
- // `hash -p FILE NAME` binds NAME to FILE, which then runs on the name's next
- // use, so a published file localizes. The value may be attached to the flag
- // cluster or arrive as the next word; scanning past bash's option terminator
- // or its first name operand only fails closed.
- const remapped = /^-[dlrt]*p(.*)$/.exec(unquoted)?.[1];
- if (remapped === "") {
- pendingHashPathValue = true;
- } else if (remapped !== undefined && isAutoPublishedScriptOperand(remapped, 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;
From 21b23b89e44e6cb8f0e74ab0cf5deef5877ca1c7 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 06:04:33 +0000
Subject: [PATCH 094/116] fix: keep the script boundary after interpreter --
and track setarch hard links
---
src/node/services/backup/payload.test.ts | 8 ++++++++
src/node/services/backup/payload.ts | 10 ++++++++++
2 files changed, 18 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index c4f8f97dbb9..6c9b9f28e1c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1754,6 +1754,10 @@ describe("backup payload", () => {
`node < ${muxRoot}/skills/launch.txt`,
`sh 0< ${muxRoot}/skills/launch.txt`,
`systemd-run --user --scope ${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`,
`cmake -P ${muxRoot}/skills/launch.txt`,
`ctest -S ${muxRoot}/skills/launch.txt`,
// Redundant separators and dot segments name the same collected file.
@@ -1799,6 +1803,8 @@ describe("backup payload", () => {
"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`,
// Two-segment merge/diff keys hold settings, not driver commands.
"git config merge.conflictstyle diff3",
"java @/tmp/opts.txt Main --port 8080",
@@ -1960,6 +1966,8 @@ describe("backup payload", () => {
// 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"],
["Lua", "lua5.4 /skills/launch.txt"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index acf264b2415..8f9576bb37e 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2125,6 +2125,11 @@ const COMMAND_CARRIER_OPERANDS = new Map([
// -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.
@@ -2394,7 +2399,12 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// argument is never mistaken for an evaluator. They also terminate env options,
// npm exec call options, and git rebase options at this parse level.
if (unquoted === "-" || unquoted === "--") {
+ const scriptOperandFollows = unquoted === "--" && pendingLanguages.size > 0;
clearInterpreterTracking();
+ // `--` only ends option parsing, so an armed interpreter's next positional
+ // is still its script operand (`python3 -- launch.txt` executes the file);
+ // a bare dash reads the script from stdin instead.
+ pendingScriptFileOperand = scriptOperandFollows;
envOperandsOnly ||= sawEnv;
// `--` ends env option parsing, so the next word is the utility; a bare `-`
// is `-i`, leaving option parsing armed.
From 664b844ac3f072e1034ce5eaa845185fa3d31302 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 06:28:40 +0000
Subject: [PATCH 095/116] fix: track Erlang erl -eval, -run, and -s evaluation
options
---
src/node/services/backup/payload.test.ts | 4 ++++
src/node/services/backup/payload.ts | 4 ++++
2 files changed, 8 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 6c9b9f28e1c..27f7b127639 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2259,6 +2259,10 @@ describe("backup payload", () => {
`node --loader=data:text/javascript,import%7BspawnSync%7Dfrom%22node%3Achild_process%22%3BspawnSync%28%22mcp%22%2C%5B%22--token%22%2C%22ghp_Abcdef1234%22%2B%22Klmno56789%22%5D%29 server.js`,
`deno run 'data:text/javascript,new(Deno.Command)("mcp",{args:["--token","ghp_Abcdef1234"+"Klmno56789"]}).outputSync()'`,
'deno eval \'const_t="ghp_Abcdef1234"+"Klmno56789"\'',
+ // erl evaluates -eval expressions, and -run/-s hand Mod:Func the remaining
+ // words (os:cmd reaches a shell from either spelling).
+ 'erl -noshell -eval \'os:cmd("mcp${IFS}--token${IFS}ghp_Abcdef1234"++"Klmno56789")\' -s init stop',
+ "erl -noshell -run os cmd 'mcp${IFS}--token${IFS}ghp_Abcdef1234\\Klmno56789'",
// Perl and Ruby cluster the numeric `-0[octal]` switch before the eval
// letter, so digits count as cluster characters alongside letters.
'perl -0e \'exec("mcp","--token","ghp_Abcdef1234"."Klmno56789")\'',
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 8f9576bb37e..d4d1c7adc9a 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2193,6 +2193,10 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{ name: /^deno$/, evalWord: /^eval$/ },
{ name: /^(?:lua|luajit)[0-9.]*$/, 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.]*$/ },
From d579b5843fb25d385c549787066ee58e447bdd31 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 06:40:43 +0000
Subject: [PATCH 096/116] fix: localize cd into the collected root,
gc.recentObjectsHook, and canonical root spellings
---
src/node/services/backup/payload.test.ts | 32 +++++++++++++++++++
src/node/services/backup/payload.ts | 39 +++++++++++++++++++++---
2 files changed, 66 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 27f7b127639..75df3bc1b01 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1758,6 +1758,10 @@ describe("backup payload", () => {
`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`,
`cmake -P ${muxRoot}/skills/launch.txt`,
`ctest -S ${muxRoot}/skills/launch.txt`,
// Redundant separators and dot segments name the same collected file.
@@ -1810,6 +1814,7 @@ describe("backup payload", () => {
"java @/tmp/opts.txt Main --port 8080",
"jshell --startup=/tmp/snippets.jsh /tmp/main.jsh",
"hash -r; mcp-server",
+ "cd /app && node server.js",
]) {
await writeFixtureFile(
muxRoot,
@@ -1858,6 +1863,31 @@ describe("backup payload", () => {
}
});
+ 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
@@ -2051,6 +2081,8 @@ describe("backup payload", () => {
"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",
"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",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d4d1c7adc9a..8bf74367cab 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1,5 +1,5 @@
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";
@@ -1762,7 +1762,7 @@ function javaOptionTakesSeparateValue(unquoted: string): boolean {
* 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)$/i;
+ /^(?: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
@@ -1802,8 +1802,18 @@ function normalizeComparablePath(value: string): string {
*/
function collectedDocumentRootPrefixes(muxRoot: string): string[] {
const absolute = new Set();
- const root = normalizeComparablePath(muxRoot);
- if (root !== "") {
+ // 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")
@@ -1858,6 +1868,14 @@ function isAutoPublishedScriptOperand(unquoted: string, rootPrefixes: readonly s
return false;
}
+/** 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}/`)
+ );
+}
+
/** 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(
@@ -2311,6 +2329,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
let pendingHashOptions = false;
+ let pendingCdDirectory = false;
let pendingScriptFileOperand = false;
let evalOperandAmbiguous = false;
// Static table entries keep the pending set bounded, so repeated interpreter words
@@ -2367,6 +2386,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
pendingHashOptions = false;
+ pendingCdDirectory = false;
clearInterpreterTracking();
}
// The word after `<` is a read redirection's filename, never a command or an
@@ -2604,6 +2624,14 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingDenoSubcommand = false;
}
}
+ if (pendingCdDirectory && !unquoted.startsWith("-")) {
+ pendingCdDirectory = false;
+ // Once the working directory moves into the collected root, any relative
+ // operand can name a published document without spelling the root at all
+ // (`cd && python3 skills/launch.txt`), so the move itself localizes;
+ // dash words are cd's own options and keep the target pending.
+ if (isUnderCollectedRoot(unquoted, 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
@@ -2640,8 +2668,9 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
if (executable === "java") pendingJavaOptions = true;
- // A builtin, so matched on the quote-removed word like the state words above.
+ // Builtins, so matched on the quote-removed word like the state words above.
if (unquoted === "hash") pendingHashOptions = true;
+ if (unquoted === "cd" || unquoted === "pushd") pendingCdDirectory = true;
if (FIND_EXECUTABLE_NAMES.has(executable)) pendingFindPrimaries = true;
const carrierSkips = COMMAND_CARRIER_OPERANDS.get(executable);
if (carrierSkips === -1) {
From 5e06d589eafb408c4794e541f92f0e2a9276207e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 07:38:50 +0000
Subject: [PATCH 097/116] fix: gate stdin localization on executable input,
resolve relative cd chains, and track java -jar
---
src/node/services/backup/payload.test.ts | 20 +++++++
src/node/services/backup/payload.ts | 74 +++++++++++++++++++-----
2 files changed, 81 insertions(+), 13 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 75df3bc1b01..ae0fc3fc3df 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1762,6 +1762,12 @@ describe("backup payload", () => {
// 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`,
+ // 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.
@@ -1815,6 +1821,13 @@ describe("backup payload", () => {
"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 consumes redirected documents as data.
+ `mcp-server < ${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,
@@ -1841,6 +1854,10 @@ describe("backup payload", () => {
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,
@@ -2011,6 +2028,9 @@ describe("backup payload", () => {
"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"],
["JShell", "jshell /skills/launch.txt"],
["JShell attached startup file", "jshell --startup=/skills/launch.txt"],
["JShell separate startup file", "jshell --startup /skills/launch.txt"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 8bf74367cab..9ca763f6042 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2329,7 +2329,13 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
let pendingHashOptions = false;
- let pendingCdDirectory = 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 evalOperandAmbiguous = false;
// Static table entries keep the pending set bounded, so repeated interpreter words
@@ -2386,7 +2392,14 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
pendingHashOptions = false;
- pendingCdDirectory = 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
@@ -2394,7 +2407,16 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// 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 (isAutoPublishedScriptOperand(unquoteShellWord(word), rootPrefixes)) return true;
+ if (isAutoPublishedScriptOperand(unquoteShellWord(word), rootPrefixes)) {
+ // 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)) continue;
@@ -2409,7 +2431,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// (`2 0;
clearInterpreterTracking();
// `--` only ends option parsing, so an armed interpreter's next positional
@@ -2607,6 +2634,10 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
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 = isAutoPublishedScriptOperand(unquoted, rootPrefixes);
pendingJavaOptions = false;
@@ -2624,13 +2655,25 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
pendingDenoSubcommand = false;
}
}
- if (pendingCdDirectory && !unquoted.startsWith("-")) {
- pendingCdDirectory = false;
- // Once the working directory moves into the collected root, any relative
- // operand can name a published document without spelling the root at all
- // (`cd && python3 skills/launch.txt`), so the move itself localizes;
- // dash words are cd's own options and keep the target pending.
- if (isUnderCollectedRoot(unquoted, rootPrefixes)) return true;
+ 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.
+ // Annotated because inference would cycle through the loop back-edge
+ // (target -> trackedCwd narrowing -> this assignment).
+ const target: string | null = /^(?:\/|\\|~|[a-z]:)/i.test(unquoted)
+ ? unquoted
+ : trackedCwd !== null
+ ? `${trackedCwd}/${unquoted}`
+ : null;
+ trackedCwd = target === null ? null : normalizeComparablePath(target);
+ 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
@@ -2660,6 +2703,7 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
.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 (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
@@ -2667,10 +2711,10 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
- if (executable === "java") pendingJavaOptions = true;
+ if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
// Builtins, so matched on the quote-removed word like the state words above.
if (unquoted === "hash") pendingHashOptions = true;
- if (unquoted === "cd" || unquoted === "pushd") pendingCdDirectory = 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) {
@@ -2711,6 +2755,10 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
? 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) {
From 8bb9fec0203330d24c2c1383eab6d00ee03da756 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 08:07:24 +0000
Subject: [PATCH 098/116] fix: localize !-valued submodule updates and bare
names on a published PATH entry
---
src/node/services/backup/payload.test.ts | 36 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 33 +++++++++++++++++++---
2 files changed, 65 insertions(+), 4 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ae0fc3fc3df..06a789fa355 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1905,6 +1905,39 @@ describe("backup payload", () => {
expect(exported.servers.private.command).toBe(REDACTED_BACKUP_VALUE);
});
+ 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],
+ // 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
@@ -2103,6 +2136,8 @@ describe("backup payload", () => {
"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",
@@ -2402,6 +2437,7 @@ describe("backup payload", () => {
"git -C /tmp status",
"git submodule status",
"git config alias.co checkout",
+ "git config submodule.vendor.update rebase",
"git config --get alias.co",
"git config core.sshCommand",
"git config user.name Alice",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 9ca763f6042..1037682d388 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2297,7 +2297,11 @@ const SHELL_STATE_WORDS = new Set([
* 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[]): boolean {
+function hasDisguisedAssignment(
+ redacted: string,
+ rootPrefixes: readonly string[],
+ publishedPathDirs: readonly string[]
+): boolean {
let commandPosition = true;
let envCommandExpected = false;
let carrierArmed = false;
@@ -2557,6 +2561,10 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
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)) {
@@ -2707,6 +2715,13 @@ function hasDisguisedAssignment(redacted: string, rootPrefixes: readonly string[
// A directly executed auto-published document runs through its shebang,
// publishing an executable relationship no marker can rehydrate elsewhere.
if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) 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(unquoted)) {
+ for (const dir of publishedPathDirs) {
+ if (isAutoPublishedScriptOperand(`${dir}/${unquoted}`, rootPrefixes)) return true;
+ }
+ }
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
@@ -3089,7 +3104,11 @@ export const MAX_ANALYZED_COMMAND_LENGTH = 32_768;
*/
export const MAX_TOTAL_ANALYZED_COMMAND_LENGTH = 8 * MAX_ANALYZED_COMMAND_LENGTH;
-function redactCommandEnvAssignments(command: string, rootPrefixes: readonly string[]): string {
+function redactCommandEnvAssignments(
+ command: string,
+ rootPrefixes: readonly string[],
+ publishedPathDirs: readonly string[]
+): 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.
@@ -3109,7 +3128,7 @@ function redactCommandEnvAssignments(command: string, rootPrefixes: readonly str
const constructs = findActiveShellConstructs(analyzed);
if (
UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
- hasDisguisedAssignment(redactedCode, rootPrefixes) ||
+ hasDisguisedAssignment(redactedCode, rootPrefixes, publishedPathDirs) ||
constructs.carrier ||
constructs.heredoc ||
constructs.processSubstitution ||
@@ -3160,6 +3179,12 @@ function redactMcpConfig(
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 publishedPathDirs = (process.env.PATH ?? "")
+ .split(path.delimiter)
+ .filter((entry) => isUnderCollectedRoot(entry, rootPrefixes));
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
@@ -3185,7 +3210,7 @@ function redactMcpConfig(
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
- redacted = redactCommandEnvAssignments(command, rootPrefixes);
+ redacted = redactCommandEnvAssignments(command, rootPrefixes, publishedPathDirs);
}
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
From 19c9cddf44892b87584f812b0bd7b640c8cfe549 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 08:38:33 +0000
Subject: [PATCH 099/116] fix: account for ambient cwd and loader execution in
backup commands
---
src/node/services/backup/payload.test.ts | 78 +++++++++-
src/node/services/backup/payload.ts | 177 +++++++++++++++++++----
2 files changed, 224 insertions(+), 31 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 06a789fa355..e335655f8c8 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1754,6 +1754,10 @@ describe("backup payload", () => {
`node < ${muxRoot}/skills/launch.txt`,
`sh 0< ${muxRoot}/skills/launch.txt`,
`systemd-run --user --scope ${muxRoot}/skills/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`,
@@ -1808,6 +1812,8 @@ describe("backup payload", () => {
"mcp-server --wrap prlimit --mode coproc",
"mcp-server < /tmp/input.json",
"mcp-server --launcher systemd-run",
+ "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",
// A control operator starts a new command, ending interpreter tracking.
"python3 --version && mcp-server -c config.toml",
@@ -1815,6 +1821,8 @@ describe("backup payload", () => {
`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",
// Two-segment merge/diff keys hold settings, not driver commands.
"git config merge.conflictstyle diff3",
"java @/tmp/opts.txt Main --port 8080",
@@ -1824,8 +1832,10 @@ describe("backup payload", () => {
// 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 consumes redirected documents as data.
+ // 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`,
]) {
@@ -1938,6 +1948,69 @@ describe("backup payload", () => {
}
});
+ 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],
+ ["--require=/opt/register.js", "python3 /opt/server.py", "python3 /opt/server.py"],
+ ["--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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
@@ -2050,6 +2123,9 @@ describe("backup payload", () => {
["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"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 1037682d388..3dd9d81da78 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1868,6 +1868,13 @@ function isAutoPublishedScriptOperand(unquoted: string, rootPrefixes: readonly s
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);
@@ -2178,6 +2185,8 @@ interface LanguageInterpreter {
evalWord?: RegExp;
attachedScriptFile?: RegExp;
separateScriptFileOption?: RegExp;
+ /** Captures an attached cwd, or an empty string when the next word is the cwd. */
+ workingDirectoryOption?: RegExp;
/**
* Attached option naming an auxiliary file the interpreter executes before its
* main operands (jshell --startup=FILE). Unlike attachedScriptFile it is not a
@@ -2230,7 +2239,11 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
name: /^w?perl[0-9.]*$/,
evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
},
- { name: /^rubyw?[0-9.]*$/, evalWord: /^-(?:(?:0[0-7]*|W[0-2]?)|[acdlnpsvwy])*e/ },
+ {
+ name: /^rubyw?[0-9.]*$/,
+ evalWord: /^-(?:(?:0[0-7]*|W[0-2]?)|[acdlnpsvwy])*e/,
+ workingDirectoryOption: /^-C(.*)$/,
+ },
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
{
name: /^(?:php[0-9.]*|php-win)$/,
@@ -2300,7 +2313,9 @@ const SHELL_STATE_WORDS = new Set([
function hasDisguisedAssignment(
redacted: string,
rootPrefixes: readonly string[],
- publishedPathDirs: readonly string[]
+ publishedPathDirs: readonly string[],
+ cdPathDirs: readonly string[],
+ inheritedNodeCodeOptions: boolean
): boolean {
let commandPosition = true;
let envCommandExpected = false;
@@ -2333,6 +2348,8 @@ function hasDisguisedAssignment(
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
let pendingHashOptions = false;
+ let pendingStartStopDaemonOptions = false;
+ let pendingStartStopDaemonExecutable = 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.
@@ -2345,9 +2362,24 @@ function hasDisguisedAssignment(
// 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 isPendingLanguageScriptOperand(value: string): boolean {
+ if (isAutoPublishedScriptOperand(value, rootPrefixes)) 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)) return true;
+ }
+ return false;
+ }
function clearInterpreterTracking(): void {
pendingLanguages.clear();
+ languageWorkingDirectories.clear();
+ pendingLanguageWorkingDirectory = null;
pendingScriptFileOperand = false;
evalOperandAmbiguous = false;
}
@@ -2396,6 +2428,8 @@ function hasDisguisedAssignment(
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
pendingHashOptions = false;
+ pendingStartStopDaemonOptions = false;
+ pendingStartStopDaemonExecutable = false;
// A bare cd goes home; a bare pushd swaps to a stack entry this scan
// cannot resolve.
if (pendingCdBuiltin === "cd") trackedCwd = "~";
@@ -2439,8 +2473,17 @@ function hasDisguisedAssignment(
) {
continue;
}
+ if (pendingLanguageWorkingDirectory !== null) {
+ const language = pendingLanguageWorkingDirectory;
+ pendingLanguageWorkingDirectory = null;
+ const directory = resolveKnownDirectory(unquoted, trackedCwd);
+ if (directory === null) languageWorkingDirectories.delete(language);
+ else languageWorkingDirectories.set(language, directory);
+ continue;
+ }
if (pendingScriptFileOperand) {
- const autoPublished = isAutoPublishedScriptOperand(unquoted, rootPrefixes);
+ const autoPublished = isPendingLanguageScriptOperand(unquoted);
+ commandConsumesStdin = false;
clearInterpreterTracking();
if (autoPublished) return true;
}
@@ -2455,17 +2498,24 @@ function hasDisguisedAssignment(
trackedCwd = null;
}
const scriptOperandFollows = unquoted === "--" && pendingLanguages.size > 0;
- clearInterpreterTracking();
- // `--` only ends option parsing, so an armed interpreter's next positional
- // is still its script operand (`python3 -- launch.txt` executes the file);
- // a bare dash reads the script from stdin instead.
- pendingScriptFileOperand = scriptOperandFollows;
+ 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 === "--") {
envCommandExpected ||= sawEnv;
sawEnv = false;
+ pendingStartStopDaemonOptions = false;
+ pendingStartStopDaemonExecutable = false;
}
pendingEnvOptionValue = false;
pendingNpmExecOptions = false;
@@ -2673,14 +2723,17 @@ function hasDisguisedAssignment(
// 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.
- // Annotated because inference would cycle through the loop back-edge
- // (target -> trackedCwd narrowing -> this assignment).
- const target: string | null = /^(?:\/|\\|~|[a-z]:)/i.test(unquoted)
- ? unquoted
- : trackedCwd !== null
- ? `${trackedCwd}/${unquoted}`
- : null;
- trackedCwd = target === null ? null : normalizeComparablePath(target);
+ 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 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
@@ -2706,27 +2759,45 @@ function hasDisguisedAssignment(
// 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;
- const executable = unquoted
- .slice(Math.max(unquoted.lastIndexOf("/"), unquoted.lastIndexOf("\\")) + 1)
+ 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 (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
+ if (isAutoPublishedScriptOperand(executableWord, rootPrefixes)) 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(unquoted)) {
+ if (!/[/\\]/.test(executableWord)) {
for (const dir of publishedPathDirs) {
- if (isAutoPublishedScriptOperand(`${dir}/${unquoted}`, rootPrefixes)) return true;
+ if (isAutoPublishedScriptOperand(`${dir}/${executableWord}`, rootPrefixes)) return true;
}
}
+ if (inheritedNodeCodeOptions && (executable === "node" || executable === "nodejs")) {
+ return true;
+ }
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
+ if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = 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;
@@ -2747,14 +2818,27 @@ function hasDisguisedAssignment(
// 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 && isAutoPublishedScriptOperand(startup, rootPrefixes)) {
return true;
}
const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
if (script !== undefined) {
- if (isAutoPublishedScriptOperand(script, rootPrefixes)) return true;
+ if (isPendingLanguageScriptOperand(script)) return true;
attachedScriptBoundary = true;
break;
}
@@ -2764,7 +2848,11 @@ function hasDisguisedAssignment(
// An evaluation word after a language interpreter hands that grammar a script.
if (pending.evalWord?.test(unquoted) === true) return true;
}
- if (attachedScriptBoundary) clearInterpreterTracking();
+ if (workingDirectoryOptionMatched) continue;
+ if (attachedScriptBoundary) {
+ commandConsumesStdin = false;
+ clearInterpreterTracking();
+ }
const language = executesHere
? LANGUAGE_INTERPRETERS.find((entry) => entry.name.test(executable))
@@ -2782,16 +2870,17 @@ function hasDisguisedAssignment(
// (`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 (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) {
+ } 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 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.
+ // 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();
}
}
@@ -3107,7 +3196,9 @@ export const MAX_TOTAL_ANALYZED_COMMAND_LENGTH = 8 * MAX_ANALYZED_COMMAND_LENGTH
function redactCommandEnvAssignments(
command: string,
rootPrefixes: readonly string[],
- publishedPathDirs: readonly string[]
+ publishedPathDirs: readonly string[],
+ cdPathDirs: readonly string[],
+ inheritedNodeCodeOptions: boolean
): string {
if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
// Analysis mirrors execution: active continuations vanish first, so every analyzer
@@ -3128,7 +3219,13 @@ function redactCommandEnvAssignments(
const constructs = findActiveShellConstructs(analyzed);
if (
UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
- hasDisguisedAssignment(redactedCode, rootPrefixes, publishedPathDirs) ||
+ hasDisguisedAssignment(
+ redactedCode,
+ rootPrefixes,
+ publishedPathDirs,
+ cdPathDirs,
+ inheritedNodeCodeOptions
+ ) ||
constructs.carrier ||
constructs.heredoc ||
constructs.processSubstitution ||
@@ -3171,6 +3268,18 @@ function isBashStartupHookVariable(name: string, value: unknown): boolean {
return name === "BASH_ENV" && value !== "" && value !== undefined;
}
+/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
+function hasInheritedNodeCodeOptions(value: unknown): boolean {
+ if (typeof value !== "string" || value === "") return false;
+ for (const match of value.matchAll(SHELL_WORD)) {
+ const option = unquoteShellWord(match[0]);
+ if (/^(?:-r(?:.*)|--(?:require|import|loader|experimental-loader)(?:=|$))/.test(option)) {
+ return true;
+ }
+ }
+ return false;
+}
+
function redactMcpConfig(
content: Buffer,
muxRoot: string
@@ -3185,6 +3294,8 @@ function redactMcpConfig(
const publishedPathDirs = (process.env.PATH ?? "")
.split(path.delimiter)
.filter((entry) => isUnderCollectedRoot(entry, rootPrefixes));
+ const cdPathDirs = (process.env.CDPATH ?? "").split(path.delimiter);
+ const inheritedNodeCodeOptions = hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS);
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
@@ -3210,7 +3321,13 @@ function redactMcpConfig(
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
- redacted = redactCommandEnvAssignments(command, rootPrefixes, publishedPathDirs);
+ redacted = redactCommandEnvAssignments(
+ command,
+ rootPrefixes,
+ publishedPathDirs,
+ cdPathDirs,
+ inheritedNodeCodeOptions
+ );
}
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
From 8cfe2e0a2d40691db5a189ca8065fc5ad9446235 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:00:14 +0000
Subject: [PATCH 100/116] fix: localize npm launchers under inherited preloads
and interactive Python under PYTHONSTARTUP
---
src/node/services/backup/payload.test.ts | 39 +++++++++++
src/node/services/backup/payload.ts | 83 +++++++++++++++---------
2 files changed, 91 insertions(+), 31 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index e335655f8c8..ba559aec2b7 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1985,6 +1985,10 @@ describe("backup payload", () => {
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],
["--require=/opt/register.js", "python3 /opt/server.py", "python3 /opt/server.py"],
["--max-old-space-size=4096", "node /opt/server.js", "node /opt/server.js"],
] as const) {
@@ -2011,6 +2015,41 @@ describe("backup payload", () => {
}
});
+ 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 3dd9d81da78..d9bfd1ea8cb 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2187,6 +2187,12 @@ interface LanguageInterpreter {
separateScriptFileOption?: 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;
/**
* Attached option naming an auxiliary file the interpreter executes before its
* main operands (jshell --startup=FILE). Unlike attachedScriptFile it is not a
@@ -2198,12 +2204,22 @@ interface LanguageInterpreter {
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 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: /^(?:py|pyw|pythonw?[0-9.]*)$/, evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/ },
+ {
+ name: /^(?:py|pyw|pythonw?[0-9.]*)$/,
+ evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/,
+ interactiveOption: /^-[bBdhOPqRsuv]*i/,
+ },
{
name: /^(?:node|nodejs)$/,
evalWord:
@@ -2313,9 +2329,7 @@ const SHELL_STATE_WORDS = new Set([
function hasDisguisedAssignment(
redacted: string,
rootPrefixes: readonly string[],
- publishedPathDirs: readonly string[],
- cdPathDirs: readonly string[],
- inheritedNodeCodeOptions: boolean
+ inherited: InheritedLaunchContext
): boolean {
let commandPosition = true;
let envCommandExpected = false;
@@ -2727,7 +2741,7 @@ function hasDisguisedAssignment(
// 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 cdPathDirs) {
+ 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;
@@ -2785,13 +2799,11 @@ function hasDisguisedAssignment(
// 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 publishedPathDirs) {
+ for (const dir of inherited.publishedPathDirs) {
if (isAutoPublishedScriptOperand(`${dir}/${executableWord}`, rootPrefixes)) return true;
}
}
- if (inheritedNodeCodeOptions && (executable === "node" || executable === "nodejs")) {
- return true;
- }
+ if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
@@ -2845,6 +2857,11 @@ function hasDisguisedAssignment(
if (pending.separateScriptFileOption?.test(unquoted) === true) {
pendingScriptFileOperand = true;
}
+ // 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;
+ }
// An evaluation word after a language interpreter hands that grammar a script.
if (pending.evalWord?.test(unquoted) === true) return true;
}
@@ -3196,9 +3213,7 @@ export const MAX_TOTAL_ANALYZED_COMMAND_LENGTH = 8 * MAX_ANALYZED_COMMAND_LENGTH
function redactCommandEnvAssignments(
command: string,
rootPrefixes: readonly string[],
- publishedPathDirs: readonly string[],
- cdPathDirs: readonly string[],
- inheritedNodeCodeOptions: boolean
+ inherited: InheritedLaunchContext
): string {
if (command.length > MAX_ANALYZED_COMMAND_LENGTH) return REDACTED_BACKUP_VALUE;
// Analysis mirrors execution: active continuations vanish first, so every analyzer
@@ -3219,13 +3234,7 @@ function redactCommandEnvAssignments(
const constructs = findActiveShellConstructs(analyzed);
if (
UNCONSUMED_ASSIGNMENT.test(redactedCode) ||
- hasDisguisedAssignment(
- redactedCode,
- rootPrefixes,
- publishedPathDirs,
- cdPathDirs,
- inheritedNodeCodeOptions
- ) ||
+ hasDisguisedAssignment(redactedCode, rootPrefixes, inherited) ||
constructs.carrier ||
constructs.heredoc ||
constructs.processSubstitution ||
@@ -3268,6 +3277,21 @@ function isBashStartupHookVariable(name: string, value: unknown): boolean {
return name === "BASH_ENV" && value !== "" && value !== undefined;
}
+/**
+ * Ambient facts from the exporting process environment that the spawned server
+ * inherits (see isBashStartupHookVariable for the channel).
+ */
+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;
+}
+
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
function hasInheritedNodeCodeOptions(value: unknown): boolean {
if (typeof value !== "string" || value === "") return false;
@@ -3291,11 +3315,14 @@ function redactMcpConfig(
// 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 publishedPathDirs = (process.env.PATH ?? "")
- .split(path.delimiter)
- .filter((entry) => isUnderCollectedRoot(entry, rootPrefixes));
- const cdPathDirs = (process.env.CDPATH ?? "").split(path.delimiter);
- const inheritedNodeCodeOptions = hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS);
+ const inherited: InheritedLaunchContext = {
+ publishedPathDirs: (process.env.PATH ?? "")
+ .split(path.delimiter)
+ .filter((entry) => isUnderCollectedRoot(entry, rootPrefixes)),
+ cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter),
+ nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
+ pythonStartupHook: isAutoPublishedScriptOperand(process.env.PYTHONSTARTUP ?? "", rootPrefixes),
+ };
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
@@ -3321,13 +3348,7 @@ function redactMcpConfig(
redacted = REDACTED_BACKUP_VALUE;
} else {
analysisBudget -= command.length;
- redacted = redactCommandEnvAssignments(
- command,
- rootPrefixes,
- publishedPathDirs,
- cdPathDirs,
- inheritedNodeCodeOptions
- );
+ redacted = redactCommandEnvAssignments(command, rootPrefixes, inherited);
}
if (redacted === command) return;
edits.push({ path: jsonPath, value: redacted });
From 733a118b58d157eef8f0acdb2b0b8bb5426b27ff Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 09:32:41 +0000
Subject: [PATCH 101/116] fix: account for env cwd, PHPRC, and Git executable
search paths
---
src/node/services/backup/payload.test.ts | 44 +++++++++++++++
src/node/services/backup/payload.ts | 71 +++++++++++++++++-------
2 files changed, 96 insertions(+), 19 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ba559aec2b7..ddaefcc3137 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1745,6 +1745,10 @@ describe("backup payload", () => {
`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`,
@@ -1780,6 +1784,8 @@ describe("backup payload", () => {
// 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`,
]) {
await writeFixtureFile(
muxRoot,
@@ -1812,6 +1818,10 @@ describe("backup payload", () => {
"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",
@@ -2050,6 +2060,40 @@ describe("backup payload", () => {
}
});
+ 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d9bfd1ea8cb..d2a505b2713 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2209,6 +2209,7 @@ interface LanguageInterpreter {
* 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 PHP_LAUNCHER_NAME = /^(?:php[0-9.]*|php-win)$/;
const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// Windows spellings count alongside the Unix names: the `py`/`pyw` launcher and the
@@ -2262,7 +2263,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
},
// -r/-R run code; -B/-E execute begin/end code blocks around per-line runs.
{
- name: /^(?:php[0-9.]*|php-win)$/,
+ name: PHP_LAUNCHER_NAME,
evalWord: /^-[nq]*[rRBE]/,
attachedScriptFile: /^(?:--file=|--process-file=|-[fF])(.+)$/,
separateScriptFileOption: /^(?:-[fF]|--file|--process-file)$/,
@@ -2342,6 +2343,7 @@ function hasDisguisedAssignment(
let pendingPrintfVariableOption = false;
let sawEnv = false;
let pendingEnvOptionValue = false;
+ let pendingEnvWorkingDirectory = false;
let pendingNpmSubcommand = false;
let pendingNpmExecOptions = false;
let pendingGitSubcommand = false;
@@ -2420,6 +2422,7 @@ function hasDisguisedAssignment(
pendingBodyName = null;
sawEnv = false;
pendingEnvOptionValue = false;
+ pendingEnvWorkingDirectory = false;
envOperandsOnly = false;
pendingPrintfVariableOption = false;
pendingNpmSubcommand = false;
@@ -2532,6 +2535,7 @@ function hasDisguisedAssignment(
pendingStartStopDaemonExecutable = false;
}
pendingEnvOptionValue = false;
+ pendingEnvWorkingDirectory = false;
pendingNpmExecOptions = false;
pendingGitRebaseOptions = false;
pendingJavaOptions = false;
@@ -2573,16 +2577,37 @@ function hasDisguisedAssignment(
// 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 (pendingEnvOptionValue) {
+ 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 if (envOptionTakesSeparateValue(unquoted)) {
- pendingEnvOptionValue = true;
- } else if (!unquoted.startsWith("-")) {
- sawEnv = false;
- executesHere = 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("-")) {
@@ -2646,18 +2671,22 @@ function hasDisguisedAssignment(
if (pendingGitSubcommand) {
if (pendingGitOptionValue) {
pendingGitOptionValue = false;
- } else if (gitOptionTakesSeparateValue(unquoted)) {
- pendingGitOptionValue = true;
- } else if (!unquoted.startsWith("-")) {
- pendingGitSubcommand = false;
- if (unquoted === "config") {
- pendingGitConfigKey = true;
- } else if (unquoted === "submodule") {
- pendingGitSubmoduleAction = true;
- } else if (unquoted === "rebase") {
- pendingGitRebaseOptions = true;
- } else if (unquoted === "filter-branch") {
- return true;
+ } else {
+ const execPath = /^--exec-path=(.*)$/.exec(unquoted)?.[1];
+ if (execPath !== undefined && isUnderCollectedRoot(execPath, rootPrefixes)) return true;
+ if (gitOptionTakesSeparateValue(unquoted)) {
+ pendingGitOptionValue = true;
+ } else if (!unquoted.startsWith("-")) {
+ pendingGitSubcommand = false;
+ if (unquoted === "config") {
+ pendingGitConfigKey = true;
+ } else if (unquoted === "submodule") {
+ pendingGitSubmoduleAction = true;
+ } else if (unquoted === "rebase") {
+ pendingGitRebaseOptions = true;
+ } else if (unquoted === "filter-branch") {
+ return true;
+ }
}
}
}
@@ -2804,6 +2833,7 @@ function hasDisguisedAssignment(
}
}
if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
+ if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
@@ -3290,6 +3320,8 @@ interface InheritedLaunchContext {
nodeCodeOptions: boolean;
/** PYTHONSTARTUP names an auto-published document. */
pythonStartupHook: boolean;
+ /** PHPRC names an auto-published configuration document. */
+ phpConfigHook: boolean;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3322,6 +3354,7 @@ function redactMcpConfig(
cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter),
nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
pythonStartupHook: isAutoPublishedScriptOperand(process.env.PYTHONSTARTUP ?? "", rootPrefixes),
+ phpConfigHook: isAutoPublishedScriptOperand(process.env.PHPRC ?? "", rootPrefixes),
};
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
From d5cc1ae6f09d3585fd7a93825ccdb6df8e1a702e Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:00:09 +0000
Subject: [PATCH 102/116] fix: track PATH scripts, runtime hooks, and wrapper
working directories
---
src/node/services/backup/payload.test.ts | 72 ++++++++++++++++
src/node/services/backup/payload.ts | 102 ++++++++++++++++++++---
2 files changed, 164 insertions(+), 10 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index ddaefcc3137..39f8ed1a0d5 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1758,6 +1758,8 @@ describe("backup payload", () => {
`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 --`,
@@ -1833,6 +1835,11 @@ describe("backup payload", () => {
`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",
@@ -1933,6 +1940,8 @@ describe("backup payload", () => {
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],
// A name that does not resolve to a published document stays portable.
["mcp-server --transport stdio", "mcp-server --transport stdio"],
] as const) {
@@ -2094,6 +2103,66 @@ describe("backup payload", () => {
}
});
+ 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
@@ -2223,6 +2292,8 @@ describe("backup payload", () => {
// -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"],
@@ -2232,6 +2303,7 @@ describe("backup payload", () => {
["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"],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index d2a505b2713..c3aec14ca7b 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1749,13 +1749,24 @@ function gitConfigOptionTakesSeparateValue(unquoted: string): boolean {
return /^(?:-[ft]|--(?:file|blob|type|comment|default))$/.test(unquoted);
}
-/** Java launcher options whose following word is an option value, not the source file. */
+/** 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 /^(?:-cp|-classpath|-p|--(?:class-path|module-path|upgrade-module-path|add-modules|enable-native-access|describe-module|add-reads|add-exports|add-opens|limit-modules|patch-module))$/.test(
+ 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[]): boolean {
+ return value
+ .split(path.delimiter)
+ .some((entry) => isAutoPublishedScriptOperand(entry, rootPrefixes));
+}
+
/**
* 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,
@@ -2185,6 +2196,8 @@ interface LanguageInterpreter {
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;
/**
@@ -2194,12 +2207,11 @@ interface LanguageInterpreter {
*/
interactiveOption?: RegExp;
/**
- * Attached option naming an auxiliary file the interpreter executes before its
- * main operands (jshell --startup=FILE). Unlike attachedScriptFile it is not a
- * script boundary: a positional load file can still follow, so tracking stays
- * armed and the option word keeps its ordinary ambiguous-dash handling. The
- * separate spelling needs no matcher, because after any dash option a published
- * operand already localizes through the armed tracking.
+ * 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;
}
@@ -2210,6 +2222,7 @@ interface LanguageInterpreter {
*/
const NODE_BASED_LAUNCHER_NAMES = new Set(["node", "nodejs", "npm", "npx", "corepack"]);
const PHP_LAUNCHER_NAME = /^(?:php[0-9.]*|php-win)$/;
+const JAVA_RUNTIME_LAUNCHER_NAME = /^(?:javaw?|jshell[0-9.]*)$/;
const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// Windows spellings count alongside the Unix names: the `py`/`pyw` launcher and the
@@ -2260,6 +2273,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
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.
{
@@ -2267,6 +2281,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
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.
@@ -2363,9 +2378,12 @@ function hasDisguisedAssignment(
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
let pendingJavaOptionValue = false;
+ let pendingJavaClassPathValue = 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.
@@ -2374,6 +2392,7 @@ function hasDisguisedAssignment(
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.
@@ -2389,6 +2408,11 @@ function hasDisguisedAssignment(
const resolved = resolveKnownDirectory(value, directory);
if (resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes)) return true;
}
+ if (pendingScriptFileUsesPath && !/[/\\]/.test(value)) {
+ for (const directory of inherited.publishedPathDirs) {
+ if (isAutoPublishedScriptOperand(`${directory}/${value}`, rootPrefixes)) return true;
+ }
+ }
return false;
}
@@ -2397,6 +2421,7 @@ function hasDisguisedAssignment(
languageWorkingDirectories.clear();
pendingLanguageWorkingDirectory = null;
pendingScriptFileOperand = false;
+ pendingScriptFileUsesPath = false;
evalOperandAmbiguous = false;
}
const words = [...redacted.matchAll(SHELL_WORD)];
@@ -2444,9 +2469,12 @@ function hasDisguisedAssignment(
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
+ pendingJavaClassPathValue = 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 = "~";
@@ -2533,6 +2561,8 @@ function hasDisguisedAssignment(
sawEnv = false;
pendingStartStopDaemonOptions = false;
pendingStartStopDaemonExecutable = false;
+ pendingSystemdRunOptions = false;
+ pendingSystemdRunWorkingDirectory = false;
}
pendingEnvOptionValue = false;
pendingEnvWorkingDirectory = false;
@@ -2542,6 +2572,7 @@ function hasDisguisedAssignment(
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
+ pendingJavaClassPathValue = false;
continue;
}
if (pendingBodyName !== null) {
@@ -2717,7 +2748,10 @@ function hasDisguisedAssignment(
pendingDenoRunScript = false;
}
}
- if (pendingJavaOptionValue) {
+ if (pendingJavaClassPathValue) {
+ pendingJavaClassPathValue = false;
+ if (javaClassPathPublishesExecutable(unquoted, rootPrefixes)) return true;
+ } else if (pendingJavaOptionValue) {
pendingJavaOptionValue = false;
} else if (pendingJavaSourceVersion) {
pendingJavaSourceVersion = false;
@@ -2729,6 +2763,14 @@ function hasDisguisedAssignment(
// localizes, and any other @-file leaves tracking armed because the options
// it expands to are not visible here.
if (isAutoPublishedScriptOperand(unquoted.slice(1), rootPrefixes)) return true;
+ } else if (isJavaClassPathOption(unquoted)) {
+ pendingJavaClassPathValue = true;
+ } else if (unquoted.startsWith("--class-path=")) {
+ if (
+ javaClassPathPublishesExecutable(unquoted.slice("--class-path=".length), rootPrefixes)
+ ) {
+ return true;
+ }
} else if (javaOptionTakesSeparateValue(unquoted)) {
pendingJavaOptionValue = true;
} else if (unquoted === "--source") {
@@ -2802,6 +2844,19 @@ function hasDisguisedAssignment(
// 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;
@@ -2834,12 +2889,14 @@ function hasDisguisedAssignment(
}
if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
+ if (inherited.javaAgentHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = 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;
@@ -2884,8 +2941,12 @@ function hasDisguisedAssignment(
attachedScriptBoundary = true;
break;
}
- if (pending.separateScriptFileOption?.test(unquoted) === true) {
+ 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.
@@ -3322,6 +3383,8 @@ interface InheritedLaunchContext {
pythonStartupHook: boolean;
/** PHPRC names an auto-published configuration document. */
phpConfigHook: boolean;
+ /** A JVM environment variable names an auto-published Java agent archive. */
+ javaAgentHook: boolean;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3336,6 +3399,21 @@ function hasInheritedNodeCodeOptions(value: unknown): boolean {
return false;
}
+function hasInheritedJavaAgent(
+ values: readonly unknown[],
+ rootPrefixes: readonly string[]
+): boolean {
+ for (const value of values) {
+ if (typeof value !== "string") continue;
+ for (const match of value.matchAll(SHELL_WORD)) {
+ const option = unquoteShellWord(match[0]);
+ const agent = /^-(?:javaagent|agentpath):([^=]+)/.exec(option)?.[1];
+ if (agent !== undefined && isAutoPublishedScriptOperand(agent, rootPrefixes)) return true;
+ }
+ }
+ return false;
+}
+
function redactMcpConfig(
content: Buffer,
muxRoot: string
@@ -3355,6 +3433,10 @@ function redactMcpConfig(
nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
pythonStartupHook: isAutoPublishedScriptOperand(process.env.PYTHONSTARTUP ?? "", rootPrefixes),
phpConfigHook: isAutoPublishedScriptOperand(process.env.PHPRC ?? "", rootPrefixes),
+ javaAgentHook: hasInheritedJavaAgent(
+ [process.env.JAVA_TOOL_OPTIONS, process.env._JAVA_OPTIONS, process.env.JDK_JAVA_OPTIONS],
+ rootPrefixes
+ ),
};
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
From a1ff70a3242281e1a810532c068330eb6b118678 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:13:31 +0000
Subject: [PATCH 103/116] fix: track mise command execution boundaries
---
src/node/services/backup/payload.test.ts | 7 +++++++
src/node/services/backup/payload.ts | 25 +++++++++++++++++++-----
2 files changed, 27 insertions(+), 5 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 39f8ed1a0d5..8891a25fbbb 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1788,6 +1788,10 @@ describe("backup payload", () => {
`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`,
+ "mise exec --command=launch.txt",
+ "mise x -c launch.txt",
]) {
await writeFixtureFile(
muxRoot,
@@ -1827,6 +1831,9 @@ describe("backup payload", () => {
"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",
+ `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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index c3aec14ca7b..dcd730fee02 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2348,7 +2348,7 @@ function hasDisguisedAssignment(
inherited: InheritedLaunchContext
): boolean {
let commandPosition = true;
- let envCommandExpected = false;
+ let wrappedCommandExpected = false;
let carrierArmed = false;
let carrierSticky = false;
let carrierOperandSkips = 0;
@@ -2361,6 +2361,8 @@ function hasDisguisedAssignment(
let pendingEnvWorkingDirectory = false;
let pendingNpmSubcommand = false;
let pendingNpmExecOptions = false;
+ let pendingMiseSubcommand = false;
+ let pendingMiseExecOptions = false;
let pendingGitSubcommand = false;
let pendingGitOptionValue = false;
let pendingGitSubmoduleAction = false;
@@ -2439,7 +2441,7 @@ function hasDisguisedAssignment(
// previous one applies: retained interpreter tracking would read the next
// command's ordinary options as evaluation (`python3 --version && mcp -c x`).
commandPosition = true;
- envCommandExpected = false;
+ wrappedCommandExpected = false;
carrierArmed = false;
carrierSticky = false;
carrierOperandSkips = 0;
@@ -2452,6 +2454,8 @@ function hasDisguisedAssignment(
pendingPrintfVariableOption = false;
pendingNpmSubcommand = false;
pendingNpmExecOptions = false;
+ pendingMiseSubcommand = false;
+ pendingMiseExecOptions = false;
pendingGitSubcommand = false;
pendingGitOptionValue = false;
pendingGitSubmoduleAction = false;
@@ -2557,8 +2561,9 @@ function hasDisguisedAssignment(
// `--` ends env option parsing, so the next word is the utility; a bare `-`
// is `-i`, leaving option parsing armed.
if (unquoted === "--") {
- envCommandExpected ||= sawEnv;
+ wrappedCommandExpected ||= sawEnv || pendingMiseExecOptions;
sawEnv = false;
+ pendingMiseExecOptions = false;
pendingStartStopDaemonOptions = false;
pendingStartStopDaemonExecutable = false;
pendingSystemdRunOptions = false;
@@ -2600,8 +2605,8 @@ function hasDisguisedAssignment(
// argument (`mcp-server --shell bash` stays published).
let executesHere = commandPosition || carrierSticky;
commandPosition = false;
- if (envCommandExpected) {
- envCommandExpected = false;
+ if (wrappedCommandExpected) {
+ wrappedCommandExpected = false;
executesHere = true;
}
// GNU env reparses its split-string value even without an assignment or literal
@@ -2721,6 +2726,15 @@ function hasDisguisedAssignment(
}
}
}
+ 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;
@@ -2892,6 +2906,7 @@ function hasDisguisedAssignment(
if (inherited.javaAgentHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
+ if (executable === "mise") pendingMiseSubcommand = true;
if (executable === "git") pendingGitSubcommand = true;
if (executable === "deno") pendingDenoSubcommand = true;
if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
From 909a7444bc7418d8690cbd583825af0348a8da3d Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:37:10 +0000
Subject: [PATCH 104/116] fix: resolve executable operands from shell cwd and
track sqlite init files
---
src/node/services/backup/payload.test.ts | 10 +++++
src/node/services/backup/payload.ts | 55 ++++++++++++++++++------
2 files changed, 52 insertions(+), 13 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 8891a25fbbb..9722ce18f6e 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1774,6 +1774,11 @@ describe("backup payload", () => {
`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`,
@@ -1790,6 +1795,8 @@ describe("backup payload", () => {
`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",
]) {
@@ -1833,6 +1840,9 @@ describe("backup payload", () => {
"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",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index dcd730fee02..af3843c4baf 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1761,10 +1761,16 @@ function javaOptionTakesSeparateValue(unquoted: string): boolean {
);
}
-function javaClassPathPublishesExecutable(value: string, rootPrefixes: readonly string[]): boolean {
- return value
- .split(path.delimiter)
- .some((entry) => isAutoPublishedScriptOperand(entry, rootPrefixes));
+function javaClassPathPublishesExecutable(
+ value: string,
+ rootPrefixes: readonly string[],
+ currentDirectory: string | null
+): boolean {
+ return value.split(path.delimiter).some((entry) => {
+ if (isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ const resolved = resolveKnownDirectory(entry, currentDirectory);
+ return resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes);
+ });
}
/**
@@ -2376,6 +2382,8 @@ function hasDisguisedAssignment(
let pendingDenoSubcommand = false;
let pendingDenoRunScript = false;
let pendingDenoRunAmbiguous = false;
+ let pendingSqliteOptions = false;
+ let pendingSqliteInitFile = false;
let pendingJavaOptions = false;
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
@@ -2402,8 +2410,14 @@ function hasDisguisedAssignment(
const languageWorkingDirectories = new Map();
let pendingLanguageWorkingDirectory: LanguageInterpreter | null = null;
- function isPendingLanguageScriptOperand(value: string): boolean {
+ function isShellResolvedPublishedOperand(value: string): boolean {
if (isAutoPublishedScriptOperand(value, rootPrefixes)) return true;
+ const resolved = resolveKnownDirectory(value, trackedCwd);
+ return resolved !== null && isAutoPublishedScriptOperand(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;
@@ -2469,6 +2483,8 @@ function hasDisguisedAssignment(
pendingDenoSubcommand = false;
pendingDenoRunScript = false;
pendingDenoRunAmbiguous = false;
+ pendingSqliteOptions = false;
+ pendingSqliteInitFile = false;
pendingJavaOptions = false;
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
@@ -2494,7 +2510,7 @@ function hasDisguisedAssignment(
// 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 (isAutoPublishedScriptOperand(unquoteShellWord(word), rootPrefixes)) {
+ 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
@@ -2568,6 +2584,8 @@ function hasDisguisedAssignment(
pendingStartStopDaemonExecutable = false;
pendingSystemdRunOptions = false;
pendingSystemdRunWorkingDirectory = false;
+ pendingSqliteOptions = false;
+ pendingSqliteInitFile = false;
}
pendingEnvOptionValue = false;
pendingEnvWorkingDirectory = false;
@@ -2750,7 +2768,7 @@ function hasDisguisedAssignment(
// (`--prefix /tmp`), so tracking stays armed until a known subcommand.
}
if (pendingDenoRunScript) {
- if (isAutoPublishedScriptOperand(unquoted, rootPrefixes)) return true;
+ 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,
@@ -2762,9 +2780,15 @@ function hasDisguisedAssignment(
pendingDenoRunScript = false;
}
}
+ if (pendingSqliteInitFile) {
+ pendingSqliteInitFile = false;
+ if (isShellResolvedPublishedOperand(unquoted)) return true;
+ } else if (pendingSqliteOptions && unquoted === "-init") {
+ pendingSqliteInitFile = true;
+ }
if (pendingJavaClassPathValue) {
pendingJavaClassPathValue = false;
- if (javaClassPathPublishesExecutable(unquoted, rootPrefixes)) return true;
+ if (javaClassPathPublishesExecutable(unquoted, rootPrefixes, trackedCwd)) return true;
} else if (pendingJavaOptionValue) {
pendingJavaOptionValue = false;
} else if (pendingJavaSourceVersion) {
@@ -2776,12 +2800,16 @@ function hasDisguisedAssignment(
// 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 (isAutoPublishedScriptOperand(unquoted.slice(1), rootPrefixes)) return true;
+ if (isShellResolvedPublishedOperand(unquoted.slice(1))) return true;
} else if (isJavaClassPathOption(unquoted)) {
pendingJavaClassPathValue = true;
} else if (unquoted.startsWith("--class-path=")) {
if (
- javaClassPathPublishesExecutable(unquoted.slice("--class-path=".length), rootPrefixes)
+ javaClassPathPublishesExecutable(
+ unquoted.slice("--class-path=".length),
+ rootPrefixes,
+ trackedCwd
+ )
) {
return true;
}
@@ -2796,7 +2824,7 @@ function hasDisguisedAssignment(
// runs, and the launcher opens it regardless of filename extension.
pendingJavaSourceFile = true;
} else if (pendingJavaSourceFile && !unquoted.startsWith("-")) {
- const autoPublished = isAutoPublishedScriptOperand(unquoted, rootPrefixes);
+ const autoPublished = isShellResolvedPublishedOperand(unquoted);
pendingJavaOptions = false;
pendingJavaSourceFile = false;
if (autoPublished) return true;
@@ -2893,7 +2921,7 @@ function hasDisguisedAssignment(
commandWordSeen = true;
// A directly executed auto-published document runs through its shebang,
// publishing an executable relationship no marker can rehydrate elsewhere.
- if (isAutoPublishedScriptOperand(executableWord, rootPrefixes)) return true;
+ 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)) {
@@ -2909,6 +2937,7 @@ function hasDisguisedAssignment(
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 === "java" || executable === "javaw") pendingJavaOptions = true;
if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = true;
if (executable === "systemd-run") pendingSystemdRunOptions = true;
@@ -2947,7 +2976,7 @@ function hasDisguisedAssignment(
break;
}
const startup = pending.attachedStartupFile?.exec(unquoted)?.[1];
- if (startup !== undefined && isAutoPublishedScriptOperand(startup, rootPrefixes)) {
+ if (startup !== undefined && isShellResolvedPublishedOperand(startup)) {
return true;
}
const script = pending.attachedScriptFile?.exec(unquoted)?.[1];
From 2dad64ebd40eec934110635896f1274af90c709b Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 10:56:53 +0000
Subject: [PATCH 105/116] fix: localize Lua launchers under an inherited
published LUA_INIT file
---
src/node/services/backup/payload.test.ts | 50 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 18 ++++++++-
2 files changed, 67 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 9722ce18f6e..5324772dccb 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2180,6 +2180,56 @@ describe("backup payload", () => {
}
});
+ 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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index af3843c4baf..54e8a807700 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2229,6 +2229,7 @@ interface LanguageInterpreter {
const NODE_BASED_LAUNCHER_NAMES = new Set(["node", "nodejs", "npm", "npx", "corepack"]);
const PHP_LAUNCHER_NAME = /^(?:php[0-9.]*|php-win)$/;
const JAVA_RUNTIME_LAUNCHER_NAME = /^(?:javaw?|jshell[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
@@ -2254,7 +2255,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// through a shell. npm needs its `exec` subcommand tracked separately below.
{ name: /^npx$/, evalWord: /^(?:-c$|--call(?:=|$))/ },
{ name: /^deno$/, evalWord: /^eval$/ },
- { name: /^(?:lua|luajit)[0-9.]*$/, evalWord: /^-e/ },
+ { 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
@@ -2932,6 +2933,7 @@ function hasDisguisedAssignment(
if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
if (inherited.javaAgentHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) return true;
+ if (inherited.luaStartupHook && LUA_LAUNCHER_NAME.test(executable)) return true;
if (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "mise") pendingMiseSubcommand = true;
@@ -3429,6 +3431,8 @@ interface InheritedLaunchContext {
phpConfigHook: boolean;
/** A JVM environment variable names an auto-published Java agent archive. */
javaAgentHook: boolean;
+ /** A LUA_INIT variable's @file form names an auto-published document. */
+ luaStartupHook: boolean;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3458,6 +3462,17 @@ function hasInheritedJavaAgent(
return false;
}
+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(value.slice(1), rootPrefixes)) return true;
+ }
+ return false;
+}
+
function redactMcpConfig(
content: Buffer,
muxRoot: string
@@ -3481,6 +3496,7 @@ function redactMcpConfig(
[process.env.JAVA_TOOL_OPTIONS, process.env._JAVA_OPTIONS, process.env.JDK_JAVA_OPTIONS],
rootPrefixes
),
+ luaStartupHook: hasInheritedLuaStartupFile(rootPrefixes),
};
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
From 4e44d8272a7c9e1980fe474e37d3cc39a7445149 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 11:09:08 +0000
Subject: [PATCH 106/116] fix: canonicalize inherited PATH entries and localize
under dynamic-loader preloads
---
src/node/services/backup/payload.test.ts | 81 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 37 +++++++++++
2 files changed, 118 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 5324772dccb..fb55398ad6c 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1984,6 +1984,47 @@ describe("backup payload", () => {
}
});
+ 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";
@@ -2230,6 +2271,46 @@ describe("backup payload", () => {
}
});
+ it("localizes commands under an inherited published dynamic-loader preload", async () => {
+ const variableNames = ["LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES"] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [variable, value, 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 foreign preload is not a collected document.
+ ["LD_PRELOAD", "/opt/lib/probe.so", "mcp-server --transport stdio"],
+ ] 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: "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 the pre-rename spelling of a renamed settings root", async () => {
const xumRoot = path.join(tempDir, ".xum");
await fs.mkdir(xumRoot);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 54e8a807700..71fb87d2f78 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2934,6 +2934,9 @@ function hasDisguisedAssignment(
if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
if (inherited.javaAgentHook && JAVA_RUNTIME_LAUNCHER_NAME.test(executable)) return true;
if (inherited.luaStartupHook && LUA_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 (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = true;
if (executable === "mise") pendingMiseSubcommand = true;
@@ -3433,6 +3436,8 @@ interface InheritedLaunchContext {
javaAgentHook: 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;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3473,6 +3478,27 @@ function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
return false;
}
+/**
+ * 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.
+ */
+function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
+ const preloadLists: ReadonlyArray = [
+ [process.env.LD_PRELOAD, /[:\s]+/],
+ [process.env.LD_AUDIT, /[:\s]+/],
+ [process.env.DYLD_INSERT_LIBRARIES, /:/],
+ ];
+ for (const [value, delimiter] of preloadLists) {
+ if (typeof value !== "string") continue;
+ for (const entry of value.split(delimiter)) {
+ if (entry !== "" && isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ }
+ }
+ return false;
+}
+
function redactMcpConfig(
content: Buffer,
muxRoot: string
@@ -3487,6 +3513,16 @@ function redactMcpConfig(
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((entry) => {
+ try {
+ return realpathSync(entry);
+ } catch {
+ return entry;
+ }
+ })
.filter((entry) => isUnderCollectedRoot(entry, rootPrefixes)),
cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter),
nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
@@ -3497,6 +3533,7 @@ function redactMcpConfig(
rootPrefixes
),
luaStartupHook: hasInheritedLuaStartupFile(rootPrefixes),
+ loaderPreloadHook: hasInheritedLoaderPreload(rootPrefixes),
};
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
From c66a998ede6a65ffa30d3c53ff869907c94bed81 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:04:31 +0000
Subject: [PATCH 107/116] fix: resolve loader search paths, inherited
PYTHONPATH/CLASSPATH archives, and git -c overrides
---
src/node/services/backup/payload.test.ts | 162 +++++++++++++++++++++--
src/node/services/backup/payload.ts | 92 +++++++++++--
2 files changed, 238 insertions(+), 16 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index fb55398ad6c..2b6593626af 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2272,20 +2272,35 @@ describe("backup payload", () => {
});
it("localizes commands under an inherited published dynamic-loader preload", async () => {
- const variableNames = ["LD_PRELOAD", "LD_AUDIT", "DYLD_INSERT_LIBRARIES"] as const;
+ 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 [variable, value, expected] of [
- ["LD_PRELOAD", `${muxRoot}/skills/launch.txt`, REDACTED_BACKUP_VALUE],
+ 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],
+ [{ 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"],
+ [{ 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];
- process.env[variable] = value;
+ for (const [name, value] of Object.entries(env)) process.env[name] = value;
await writeFixtureFile(
muxRoot,
"mcp.jsonc",
@@ -2311,6 +2326,137 @@ describe("backup payload", () => {
}
});
+ 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("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);
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 71fb87d2f78..52a9cc40de2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1792,6 +1792,24 @@ 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
@@ -2227,6 +2245,7 @@ interface LanguageInterpreter {
* 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 LUA_LAUNCHER_NAME = /^(?:lua|luajit)[0-9.]*$/;
@@ -2237,7 +2256,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
// grammars under different executable names. Short eval flags can follow only flags
// that consume no attached operand: `-Bc` evaluates, while `-Wsource` does not.
{
- name: /^(?:py|pyw|pythonw?[0-9.]*)$/,
+ name: PYTHON_LAUNCHER_NAME,
evalWord: /^-[bBdEhiIOPqRsuSvVx]*c/,
interactiveOption: /^-[bBdhOPqRsuv]*i/,
},
@@ -2372,6 +2391,8 @@ function hasDisguisedAssignment(
let pendingMiseExecOptions = false;
let pendingGitSubcommand = false;
let pendingGitOptionValue = false;
+ let pendingGitConfigOverrideValue = false;
+ let pendingGitConfigEnvValue = false;
let pendingGitSubmoduleAction = false;
let pendingGitRebaseOptions = false;
let pendingGitConfigKey = false;
@@ -2473,6 +2494,8 @@ function hasDisguisedAssignment(
pendingMiseExecOptions = false;
pendingGitSubcommand = false;
pendingGitOptionValue = false;
+ pendingGitConfigOverrideValue = false;
+ pendingGitConfigEnvValue = false;
pendingGitSubmoduleAction = false;
pendingGitRebaseOptions = false;
pendingGitConfigKey = false;
@@ -2523,7 +2546,19 @@ function hasDisguisedAssignment(
}
continue;
}
- if (CONSUMED_ASSIGNMENT.test(word)) continue;
+ if (CONSUMED_ASSIGNMENT.test(word)) {
+ // 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
@@ -2726,11 +2761,20 @@ function hasDisguisedAssignment(
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") {
@@ -2933,6 +2977,10 @@ function hasDisguisedAssignment(
if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
if (inherited.javaAgentHook && 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;
// The dynamic loader injects an inherited published preload into every
// dynamically linked launcher, ahead of whatever the command runs.
@@ -3430,6 +3478,10 @@ interface InheritedLaunchContext {
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 environment variable names an auto-published Java agent archive. */
@@ -3483,17 +3535,33 @@ function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
* 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 hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
- const preloadLists: ReadonlyArray = [
- [process.env.LD_PRELOAD, /[:\s]+/],
- [process.env.LD_AUDIT, /[:\s]+/],
- [process.env.DYLD_INSERT_LIBRARIES, /:/],
+ const linuxSearchDirs = (process.env.LD_LIBRARY_PATH ?? "").split(/[:;]/);
+ const dyldSearchDirs = [
+ ...(process.env.DYLD_LIBRARY_PATH ?? "").split(":"),
+ ...(process.env.DYLD_FALLBACK_LIBRARY_PATH ?? "").split(":"),
+ ];
+ 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] of preloadLists) {
+ for (const [value, delimiter, searchDirs] of preloadLists) {
if (typeof value !== "string") continue;
for (const entry of value.split(delimiter)) {
- if (entry !== "" && isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ if (entry === "") continue;
+ if (/[/\\]/.test(entry)) {
+ if (isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ } else {
+ for (const dir of searchDirs) {
+ if (dir !== "" && isAutoPublishedScriptOperand(`${dir}/${entry}`, rootPrefixes)) {
+ return true;
+ }
+ }
+ }
}
}
return false;
@@ -3527,6 +3595,14 @@ function redactMcpConfig(
cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter),
nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
pythonStartupHook: isAutoPublishedScriptOperand(process.env.PYTHONSTARTUP ?? "", rootPrefixes),
+ pythonPathHook: (process.env.PYTHONPATH ?? "")
+ .split(path.delimiter)
+ .some((entry) => isAutoPublishedScriptOperand(entry, rootPrefixes)),
+ javaClassPathHook: javaClassPathPublishesExecutable(
+ process.env.CLASSPATH ?? "",
+ rootPrefixes,
+ null
+ ),
phpConfigHook: isAutoPublishedScriptOperand(process.env.PHPRC ?? "", rootPrefixes),
javaAgentHook: hasInheritedJavaAgent(
[process.env.JAVA_TOOL_OPTIONS, process.env._JAVA_OPTIONS, process.env.JDK_JAVA_OPTIONS],
From 8aaafbd49965050c5fffbfad97256d427a86a075 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:30:18 +0000
Subject: [PATCH 108/116] fix: canonicalize inherited env paths, track boot
class paths and sqlite -cmd
---
src/node/services/backup/payload.test.ts | 151 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 109 ++++++++++++----
2 files changed, 233 insertions(+), 27 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 2b6593626af..6660379bfa2 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2411,6 +2411,157 @@ describe("backup payload", () => {
}
});
+ 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,
+ ],
+ // 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 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],
+ // 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 command-valued git -c and --config-env overrides", async () => {
for (const [command, expected] of [
// The assignment redaction replaces an unquoted `-c` value before the
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 52a9cc40de2..0fb920ce061 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1918,6 +1918,22 @@ function isUnderCollectedRoot(unquoted: string, rootPrefixes: readonly string[])
);
}
+/**
+ * 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(
@@ -2828,8 +2844,14 @@ function hasDisguisedAssignment(
if (pendingSqliteInitFile) {
pendingSqliteInitFile = false;
if (isShellResolvedPublishedOperand(unquoted)) return true;
- } else if (pendingSqliteOptions && unquoted === "-init") {
+ } else if (pendingSqliteOptions && /^--?init$/.test(unquoted)) {
+ // SQLite accepts every option with one or two leading dashes.
pendingSqliteInitFile = true;
+ } 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;
}
if (pendingJavaClassPathValue) {
pendingJavaClassPathValue = false;
@@ -2976,7 +2998,9 @@ function hasDisguisedAssignment(
}
if (inherited.nodeCodeOptions && NODE_BASED_LAUNCHER_NAMES.has(executable)) return true;
if (inherited.phpConfigHook && PHP_LAUNCHER_NAME.test(executable)) return true;
- if (inherited.javaAgentHook && JAVA_RUNTIME_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;
@@ -3484,8 +3508,8 @@ interface InheritedLaunchContext {
javaClassPathHook: boolean;
/** PHPRC names an auto-published configuration document. */
phpConfigHook: boolean;
- /** A JVM environment variable names an auto-published Java agent archive. */
- javaAgentHook: 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. */
@@ -3504,7 +3528,12 @@ function hasInheritedNodeCodeOptions(value: unknown): boolean {
return false;
}
-function hasInheritedJavaAgent(
+/**
+ * JVM option variables inject execution into every launched JVM: an agent
+ * archive runs its premain, and a boot-class-path entry supplies executable
+ * classes ahead of the application regardless of filename extension.
+ */
+function hasInheritedJavaLaunchOptions(
values: readonly unknown[],
rootPrefixes: readonly string[]
): boolean {
@@ -3513,7 +3542,22 @@ function hasInheritedJavaAgent(
for (const match of value.matchAll(SHELL_WORD)) {
const option = unquoteShellWord(match[0]);
const agent = /^-(?:javaagent|agentpath):([^=]+)/.exec(option)?.[1];
- if (agent !== undefined && isAutoPublishedScriptOperand(agent, rootPrefixes)) return true;
+ if (
+ agent !== undefined &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(agent), rootPrefixes)
+ ) {
+ return true;
+ }
+ const bootClassPath = /^-Xbootclasspath(?:\/[ap])?:(.+)$/.exec(option)?.[1];
+ if (
+ bootClassPath
+ ?.split(path.delimiter)
+ .some((entry) =>
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ) === true
+ ) {
+ return true;
+ }
}
}
return false;
@@ -3525,7 +3569,9 @@ function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
// @ 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(value.slice(1), rootPrefixes)) return true;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(value.slice(1)), rootPrefixes)) {
+ return true;
+ }
}
return false;
}
@@ -3539,11 +3585,13 @@ function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
* inherited library search path before the default directories.
*/
function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
- const linuxSearchDirs = (process.env.LD_LIBRARY_PATH ?? "").split(/[:;]/);
+ 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],
@@ -3554,10 +3602,15 @@ function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
for (const entry of value.split(delimiter)) {
if (entry === "") continue;
if (/[/\\]/.test(entry)) {
- if (isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)) {
+ return true;
+ }
} else {
for (const dir of searchDirs) {
- if (dir !== "" && isAutoPublishedScriptOperand(`${dir}/${entry}`, rootPrefixes)) {
+ if (
+ dir !== "" &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(`${dir}/${entry}`), rootPrefixes)
+ ) {
return true;
}
}
@@ -3584,27 +3637,29 @@ function redactMcpConfig(
// 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((entry) => {
- try {
- return realpathSync(entry);
- } catch {
- return entry;
- }
- })
+ .map(canonicalizeInheritedPath)
.filter((entry) => isUnderCollectedRoot(entry, rootPrefixes)),
- cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter),
+ cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter).map(canonicalizeInheritedPath),
nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
- pythonStartupHook: isAutoPublishedScriptOperand(process.env.PYTHONSTARTUP ?? "", rootPrefixes),
+ pythonStartupHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.PYTHONSTARTUP ?? ""),
+ rootPrefixes
+ ),
pythonPathHook: (process.env.PYTHONPATH ?? "")
.split(path.delimiter)
- .some((entry) => isAutoPublishedScriptOperand(entry, rootPrefixes)),
- javaClassPathHook: javaClassPathPublishesExecutable(
- process.env.CLASSPATH ?? "",
- rootPrefixes,
- null
+ .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
),
- phpConfigHook: isAutoPublishedScriptOperand(process.env.PHPRC ?? "", rootPrefixes),
- javaAgentHook: hasInheritedJavaAgent(
+ javaLaunchOptionsHook: hasInheritedJavaLaunchOptions(
[process.env.JAVA_TOOL_OPTIONS, process.env._JAVA_OPTIONS, process.env.JDK_JAVA_OPTIONS],
rootPrefixes
),
From 225b3dbffbdb70ffe1ef9faee796d379cd3afbe8 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 12:38:28 +0000
Subject: [PATCH 109/116] fix: localize direct java boot-class-path archives
---
src/node/services/backup/payload.test.ts | 28 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 12 ++++++++++
2 files changed, 40 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 6660379bfa2..8a0fdd2d66d 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2534,6 +2534,34 @@ describe("backup payload", () => {
}
});
+ it("localizes direct java boot-class-path archives", async () => {
+ for (const [command, expected] of [
+ [`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.
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 0fb920ce061..42728763894 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2880,6 +2880,18 @@ function hasDisguisedAssignment(
) {
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") {
From 97f6154f817d33deb13082bbf02a13607653a4f5 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:09:47 +0000
Subject: [PATCH 110/116] fix: localize under inherited git config overrides
and openssl config operands
---
src/node/services/backup/payload.test.ts | 150 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 46 +++++++
2 files changed, 196 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 8a0fdd2d66d..97825faa176 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2590,6 +2590,156 @@ describe("backup payload", () => {
}
});
+ it("localizes git launchers under inherited config overrides", async () => {
+ const variableNames = [
+ "GIT_CONFIG_COUNT",
+ "GIT_CONFIG_KEY_0",
+ "GIT_CONFIG_VALUE_0",
+ "GIT_CONFIG_GLOBAL",
+ "GIT_CONFIG_SYSTEM",
+ ] as const;
+ const originals = variableNames.map((name) => process.env[name]);
+ try {
+ for (const [env, command, expected] of [
+ // 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,
+ ],
+ // 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 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 command-valued git -c and --config-env overrides", async () => {
for (const [command, expected] of [
// The assignment redaction replaces an unquoted `-c` value before the
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 42728763894..de3a1cca2ce 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2422,6 +2422,8 @@ function hasDisguisedAssignment(
let pendingDenoRunAmbiguous = false;
let pendingSqliteOptions = false;
let pendingSqliteInitFile = false;
+ let pendingOpensslOptions = false;
+ let pendingOpensslConfigFile = false;
let pendingJavaOptions = false;
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
@@ -2525,6 +2527,8 @@ function hasDisguisedAssignment(
pendingDenoRunAmbiguous = false;
pendingSqliteOptions = false;
pendingSqliteInitFile = false;
+ pendingOpensslOptions = false;
+ pendingOpensslConfigFile = false;
pendingJavaOptions = false;
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
@@ -2853,6 +2857,14 @@ function hasDisguisedAssignment(
// quoting), so it localizes like other eval words.
return 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 (pendingJavaClassPathValue) {
pendingJavaClassPathValue = false;
if (javaClassPathPublishesExecutable(unquoted, rootPrefixes, trackedCwd)) return true;
@@ -3021,12 +3033,15 @@ function hasDisguisedAssignment(
// 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 (executable === "env") sawEnv = true;
if (executable === "npm") pendingNpmSubcommand = 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 (executable === "java" || executable === "javaw") pendingJavaOptions = true;
if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = true;
if (executable === "systemd-run") pendingSystemdRunOptions = true;
@@ -3526,6 +3541,10 @@ interface InheritedLaunchContext {
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;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3632,6 +3651,28 @@ function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
return false;
}
+/**
+ * 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.
+ */
+function hasInheritedGitConfig(rootPrefixes: readonly string[]): boolean {
+ 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_GLOBAL, process.env.GIT_CONFIG_SYSTEM]) {
+ if (typeof file !== "string") continue;
+ if (isAutoPublishedScriptOperand(canonicalizeInheritedPath(file), rootPrefixes)) return true;
+ }
+ return false;
+}
+
function redactMcpConfig(
content: Buffer,
muxRoot: string
@@ -3677,6 +3718,11 @@ function redactMcpConfig(
),
luaStartupHook: hasInheritedLuaStartupFile(rootPrefixes),
loaderPreloadHook: hasInheritedLoaderPreload(rootPrefixes),
+ gitConfigHook: hasInheritedGitConfig(rootPrefixes),
+ opensslConfigHook: isAutoPublishedScriptOperand(
+ canonicalizeInheritedPath(process.env.OPENSSL_CONF ?? ""),
+ rootPrefixes
+ ),
};
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
From ee5108ab73d7df3d7683893e4461dd618a0f7a42 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:20:16 +0000
Subject: [PATCH 111/116] fix: localize git launchers under inherited execution
hooks
---
src/node/services/backup/payload.test.ts | 50 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 39 ++++++++++++++++++
2 files changed, 89 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 97825faa176..c72e6c26822 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2597,6 +2597,18 @@ describe("backup payload", () => {
"GIT_CONFIG_VALUE_0",
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
+ "GIT_SSH_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 {
@@ -2622,6 +2634,44 @@ describe("backup payload", () => {
"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_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" },
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index de3a1cca2ce..50ba57d2435 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -3673,6 +3673,44 @@ function hasInheritedGitConfig(rootPrefixes: readonly string[]): boolean {
return false;
}
+/**
+ * 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 hasInheritedGitExecutionHook(
+ rootPrefixes: readonly string[],
+ inherited: InheritedLaunchContext
+): boolean {
+ const nestedContext = { ...inherited, gitConfigHook: false };
+ for (const command of [
+ process.env.GIT_SSH_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;
+ }
+ }
+ 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,
muxRoot: string
@@ -3724,6 +3762,7 @@ function redactMcpConfig(
rootPrefixes
),
};
+ inherited.gitConfigHook ||= hasInheritedGitExecutionHook(rootPrefixes, inherited);
const text = content.toString("utf-8");
const { parsed: root, tree } = parseJsoncObjectWithTree(text, "mcp.jsonc");
const redactionPaths: BackupRedactionPath[] = [];
From 8774b658b4ca014179175ecedd7d500ea750ce44 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 13:42:47 +0000
Subject: [PATCH 112/116] fix: track deprecated Git parameters and LLDB command
files
---
src/node/services/backup/payload.test.ts | 50 ++++++++++++++++++++++++
src/node/services/backup/payload.ts | 37 +++++++++++++++++-
2 files changed, 86 insertions(+), 1 deletion(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index c72e6c26822..eadcc445fdc 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2592,6 +2592,8 @@ describe("backup payload", () => {
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",
@@ -2613,6 +2615,22 @@ describe("backup payload", () => {
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.
[
{
@@ -2724,6 +2742,38 @@ describe("backup payload", () => {
}
});
+ 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 openssl configuration operands", async () => {
for (const [command, expected] of [
[`openssl req -config ${muxRoot}/skills/config.txt -new`, REDACTED_BACKUP_VALUE],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 50ba57d2435..73f0e9f7140 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2424,6 +2424,8 @@ function hasDisguisedAssignment(
let pendingSqliteInitFile = false;
let pendingOpensslOptions = false;
let pendingOpensslConfigFile = false;
+ let pendingLldbOptions = false;
+ let pendingLldbSourceFile = false;
let pendingJavaOptions = false;
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
@@ -2529,6 +2531,8 @@ function hasDisguisedAssignment(
pendingSqliteInitFile = false;
pendingOpensslOptions = false;
pendingOpensslConfigFile = false;
+ pendingLldbOptions = false;
+ pendingLldbSourceFile = false;
pendingJavaOptions = false;
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
@@ -2865,6 +2869,28 @@ function hasDisguisedAssignment(
} else if (pendingOpensslOptions && /^--?config$/.test(unquoted)) {
pendingOpensslConfigFile = 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 (pendingJavaClassPathValue) {
pendingJavaClassPathValue = false;
if (javaClassPathPublishesExecutable(unquoted, rootPrefixes, trackedCwd)) return true;
@@ -3042,6 +3068,7 @@ function hasDisguisedAssignment(
if (executable === "deno") pendingDenoSubcommand = true;
if (/^sqlite3[0-9.]*$/.test(executable)) pendingSqliteOptions = true;
if (executable === "openssl") pendingOpensslOptions = true;
+ if (/^lldb(?:-[0-9.]+)?$/.test(executable)) pendingLldbOptions = true;
if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = true;
if (executable === "systemd-run") pendingSystemdRunOptions = true;
@@ -3658,6 +3685,10 @@ function hasInheritedLoaderPreload(rootPrefixes: readonly string[]): boolean {
* key or a published replacement file localizes git launchers.
*/
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)) {
@@ -3666,7 +3697,11 @@ function hasInheritedGitConfig(rootPrefixes: readonly string[]): boolean {
if (typeof value === "string" && gitConfigOverrideNamesSensitiveKey(value)) return true;
}
}
- for (const file of [process.env.GIT_CONFIG_GLOBAL, process.env.GIT_CONFIG_SYSTEM]) {
+ 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;
}
From 9f57e374d3d3f31d5223db57570ff3cb632e766a Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 14:05:17 +0000
Subject: [PATCH 113/116] fix: track Perl debugger, uv run, GDB commands, and
Ninja builds
---
src/node/services/backup/payload.test.ts | 125 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 84 +++++++++++++++
2 files changed, 209 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index eadcc445fdc..74a2c5bd881 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2742,6 +2742,131 @@ describe("backup payload", () => {
}
});
+ 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 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],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 73f0e9f7140..9db64c5b3b2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1941,6 +1941,19 @@ const NPM_SUBCOMMANDS = new Set(
)
);
+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}$`);
@@ -2246,6 +2259,8 @@ interface LanguageInterpreter {
* 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,
@@ -2310,6 +2325,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{
name: /^w?perl[0-9.]*$/,
evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
+ debuggerOption: /^-d(?:$|[:t])/,
},
{
name: /^rubyw?[0-9.]*$/,
@@ -2403,6 +2419,9 @@ function hasDisguisedAssignment(
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;
@@ -2426,6 +2445,10 @@ function hasDisguisedAssignment(
let pendingOpensslConfigFile = false;
let pendingLldbOptions = false;
let pendingLldbSourceFile = false;
+ let pendingGdbOptions = false;
+ let pendingGdbCommandFile = false;
+ let pendingNinjaOptions = false;
+ let pendingNinjaBuildFile = false;
let pendingJavaOptions = false;
let pendingJavaSourceVersion = false;
let pendingJavaSourceFile = false;
@@ -2510,6 +2533,9 @@ function hasDisguisedAssignment(
pendingPrintfVariableOption = false;
pendingNpmSubcommand = false;
pendingNpmExecOptions = false;
+ pendingUvSubcommand = false;
+ pendingUvOptionValue = false;
+ pendingUvToolSubcommand = false;
pendingMiseSubcommand = false;
pendingMiseExecOptions = false;
pendingGitSubcommand = false;
@@ -2533,6 +2559,10 @@ function hasDisguisedAssignment(
pendingOpensslConfigFile = false;
pendingLldbOptions = false;
pendingLldbSourceFile = false;
+ pendingGdbOptions = false;
+ pendingGdbCommandFile = false;
+ pendingNinjaOptions = false;
+ pendingNinjaBuildFile = false;
pendingJavaOptions = false;
pendingJavaSourceVersion = false;
pendingJavaSourceFile = false;
@@ -2813,6 +2843,26 @@ function hasDisguisedAssignment(
}
}
}
+ 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;
}
@@ -2869,6 +2919,31 @@ function hasDisguisedAssignment(
} else if (pendingOpensslOptions && /^--?config$/.test(unquoted)) {
pendingOpensslConfigFile = true;
}
+ 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;
@@ -3063,12 +3138,15 @@ function hasDisguisedAssignment(
if (inherited.opensslConfigHook && executable === "openssl") 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 (/^lldb(?:-[0-9.]+)?$/.test(executable)) pendingLldbOptions = true;
+ if (/^(?:.*-)?gdb(?:-multiarch)?(?:-[0-9.]+)?$/.test(executable)) pendingGdbOptions = true;
+ if (/^ninja(?:-build)?$/.test(executable)) pendingNinjaOptions = true;
if (executable === "java" || executable === "javaw") pendingJavaOptions = true;
if (executable === "start-stop-daemon") pendingStartStopDaemonOptions = true;
if (executable === "systemd-run") pendingSystemdRunOptions = true;
@@ -3128,6 +3206,9 @@ function hasDisguisedAssignment(
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;
}
@@ -3572,6 +3653,8 @@ interface InheritedLaunchContext {
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;
}
/** Whether inherited NODE_OPTIONS asks Node to execute a preload/import module. */
@@ -3796,6 +3879,7 @@ function redactMcpConfig(
canonicalizeInheritedPath(process.env.OPENSSL_CONF ?? ""),
rootPrefixes
),
+ perlDebuggerHook: (process.env.PERL5DB ?? "").trim() !== "",
};
inherited.gitConfigHook ||= hasInheritedGitExecutionHook(rootPrefixes, inherited);
const text = content.toString("utf-8");
From ad20e8e74004b6e424066fd6ad386e998265a0c2 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 14:13:40 +0000
Subject: [PATCH 114/116] fix: expand inherited JVM argument-file coverage
---
src/node/services/backup/payload.test.ts | 3 +++
src/node/services/backup/payload.ts | 8 ++++++++
2 files changed, 11 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 74a2c5bd881..37adea3e290 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -2497,6 +2497,9 @@ describe("backup payload", () => {
"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"],
// Other launchers do not consult the JVM option variables.
[
"JAVA_TOOL_OPTIONS",
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 9db64c5b3b2..4e8c28c9386 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -3682,6 +3682,14 @@ function hasInheritedJavaLaunchOptions(
if (typeof value !== "string") continue;
for (const match of value.matchAll(SHELL_WORD)) {
const option = unquoteShellWord(match[0]);
+ // The JVM expands an inherited @argument file into options before
+ // parsing, so a published file can inject an agent or boot class path.
+ if (
+ option.startsWith("@") &&
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(option.slice(1)), rootPrefixes)
+ ) {
+ return true;
+ }
const agent = /^-(?:javaagent|agentpath):([^=]+)/.exec(option)?.[1];
if (
agent !== undefined &&
From f68116c450fe30a4d382e58377a414941f8554f1 Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 14:52:27 +0000
Subject: [PATCH 115/116] fix: track direct runtime loaders and inherited build
hooks
---
src/node/services/backup/payload.test.ts | 182 +++++++++++++++++++++++
src/node/services/backup/payload.ts | 152 +++++++++++++++++++
2 files changed, 334 insertions(+)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 37adea3e290..8e2c7140125 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1959,6 +1959,8 @@ describe("backup payload", () => {
["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) {
@@ -2539,6 +2541,9 @@ describe("backup payload", () => {
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 -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.
@@ -2570,6 +2575,9 @@ describe("backup payload", () => {
// -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:"],
@@ -2745,6 +2753,150 @@ describe("backup payload", () => {
}
});
+ 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 {
@@ -2968,6 +3120,36 @@ describe("backup payload", () => {
}
});
+ 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
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 4e8c28c9386..22fc9ccdde2 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -2325,6 +2325,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
{
name: /^w?perl[0-9.]*$/,
evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
+ pathScriptFileOption: /^-S$/,
debuggerOption: /^-d(?:$|[:t])/,
},
{
@@ -2426,6 +2427,8 @@ function hasDisguisedAssignment(
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;
@@ -2441,6 +2444,15 @@ function hasDisguisedAssignment(
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;
@@ -2540,6 +2552,8 @@ function hasDisguisedAssignment(
pendingMiseExecOptions = false;
pendingGitSubcommand = false;
pendingGitOptionValue = false;
+ pendingGitRemoteProgramOptions = null;
+ pendingGitRemoteProgramValue = false;
pendingGitConfigOverrideValue = false;
pendingGitConfigEnvValue = false;
pendingGitSubmoduleAction = false;
@@ -2555,6 +2569,15 @@ function hasDisguisedAssignment(
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;
@@ -2807,6 +2830,21 @@ function hasDisguisedAssignment(
}
}
}
+ 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;
@@ -2833,6 +2871,12 @@ function hasDisguisedAssignment(
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") {
@@ -2899,17 +2943,59 @@ function hasDisguisedAssignment(
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 (pendingOpensslConfigFile) {
pendingOpensslConfigFile = false;
@@ -2919,6 +3005,27 @@ function hasDisguisedAssignment(
} 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;
@@ -2993,6 +3100,9 @@ function hasDisguisedAssignment(
) {
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.
@@ -3136,6 +3246,10 @@ function hasDisguisedAssignment(
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;
@@ -3144,6 +3258,10 @@ function hasDisguisedAssignment(
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;
@@ -3655,6 +3773,10 @@ interface InheritedLaunchContext {
opensslConfigHook: boolean;
/** A non-empty PERL5DB program runs when Perl's debugger is enabled. */
perlDebuggerHook: 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 module. */
@@ -3725,6 +3847,27 @@ function hasInheritedLuaStartupFile(rootPrefixes: readonly string[]): boolean {
return false;
}
+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;
+}
+
/**
* The dynamic loader runs inherited preload/audit objects inside every
* dynamically linked launcher before the command, and accepts a shared object
@@ -3888,6 +4031,15 @@ function redactMcpConfig(
rootPrefixes
),
perlDebuggerHook: (process.env.PERL5DB ?? "").trim() !== "",
+ 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");
From e6352db3625ec7eb55c17feff7fbc2caab762e1f Mon Sep 17 00:00:00 2001
From: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
Date: Fri, 28 Aug 2026 15:28:31 +0000
Subject: [PATCH 116/116] fix: cover symlinked operands and inherited runtime
hooks
---
src/node/services/backup/payload.test.ts | 187 +++++++++++++++++++++
src/node/services/backup/payload.ts | 196 ++++++++++++++++++++---
2 files changed, 359 insertions(+), 24 deletions(-)
diff --git a/src/node/services/backup/payload.test.ts b/src/node/services/backup/payload.test.ts
index 8e2c7140125..6a7104242cf 100644
--- a/src/node/services/backup/payload.test.ts
+++ b/src/node/services/backup/payload.test.ts
@@ -1949,6 +1949,38 @@ describe("backup payload", () => {
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.
@@ -2068,7 +2100,27 @@ describe("backup payload", () => {
[`--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;
@@ -2094,6 +2146,36 @@ describe("backup payload", () => {
}
});
+ 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 {
@@ -2502,6 +2584,18 @@ describe("backup payload", () => {
// 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",
@@ -2544,6 +2638,11 @@ describe("backup payload", () => {
[`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.
@@ -2611,6 +2710,7 @@ describe("backup payload", () => {
"GIT_CONFIG_GLOBAL",
"GIT_CONFIG_SYSTEM",
"GIT_SSH_COMMAND",
+ "GIT_PROXY_COMMAND",
"GIT_SSH",
"GIT_ASKPASS",
"SSH_ASKPASS",
@@ -2669,6 +2769,11 @@ describe("backup payload", () => {
"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",
@@ -2939,6 +3044,56 @@ describe("backup payload", () => {
}
});
+ 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],
@@ -3054,6 +3209,38 @@ describe("backup payload", () => {
}
});
+ 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],
diff --git a/src/node/services/backup/payload.ts b/src/node/services/backup/payload.ts
index 22fc9ccdde2..ddc8d39f189 100644
--- a/src/node/services/backup/payload.ts
+++ b/src/node/services/backup/payload.ts
@@ -1767,12 +1767,33 @@ function javaClassPathPublishesExecutable(
currentDirectory: string | null
): boolean {
return value.split(path.delimiter).some((entry) => {
- if (isAutoPublishedScriptOperand(entry, rootPrefixes)) return true;
+ if (
+ isAutoPublishedScriptOperand(entry, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
+ ) {
+ return true;
+ }
const resolved = resolveKnownDirectory(entry, currentDirectory);
- return resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes);
+ 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,
@@ -2279,6 +2300,7 @@ const NODE_BASED_LAUNCHER_NAMES = new Set(["node", "nodejs", "npm", "npx", "core
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[] = [
@@ -2295,6 +2317,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
name: /^(?:node|nodejs)$/,
evalWord:
/^(?:(?:--eval|--print|--import|--loader|--experimental-loader|--require)(?:=|$)|-[epr])/,
+ attachedStartupFile: /^--snapshot-blob=(.+)$/,
},
{
name: /^bun$/,
@@ -2323,7 +2346,7 @@ const LANGUAGE_INTERPRETERS: LanguageInterpreter[] = [
},
{ name: /^rscript$/, evalWord: /^(?:-e$|--expression(?:=|$))/ },
{
- name: /^w?perl[0-9.]*$/,
+ name: PERL_LAUNCHER_NAME,
evalWord: /^-(?:(?:0(?:x[0-9A-Fa-f]+|[0-7]*))|l[0-7]*|[acfnpsStTuUvVwWX])*[eE]/,
pathScriptFileOption: /^-S$/,
debuggerOption: /^-d(?:$|[:t])/,
@@ -2461,11 +2484,14 @@ function hasDisguisedAssignment(
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;
@@ -2488,9 +2514,18 @@ function hasDisguisedAssignment(
let pendingLanguageWorkingDirectory: LanguageInterpreter | null = null;
function isShellResolvedPublishedOperand(value: string): boolean {
- if (isAutoPublishedScriptOperand(value, rootPrefixes)) return true;
+ if (
+ isAutoPublishedScriptOperand(value, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(value), rootPrefixes)
+ ) {
+ return true;
+ }
const resolved = resolveKnownDirectory(value, trackedCwd);
- return resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes);
+ return (
+ resolved !== null &&
+ (isAutoPublishedScriptOperand(resolved, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(resolved), rootPrefixes))
+ );
}
function isPendingLanguageScriptOperand(value: string): boolean {
@@ -2499,7 +2534,13 @@ function hasDisguisedAssignment(
const directory = languageWorkingDirectories.get(language);
if (directory === undefined) continue;
const resolved = resolveKnownDirectory(value, directory);
- if (resolved !== null && isAutoPublishedScriptOperand(resolved, rootPrefixes)) return true;
+ if (
+ resolved !== null &&
+ (isAutoPublishedScriptOperand(resolved, rootPrefixes) ||
+ isAutoPublishedScriptOperand(canonicalizeInheritedPath(resolved), rootPrefixes))
+ ) {
+ return true;
+ }
}
if (pendingScriptFileUsesPath && !/[/\\]/.test(value)) {
for (const directory of inherited.publishedPathDirs) {
@@ -2586,11 +2627,14 @@ function hasDisguisedAssignment(
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;
@@ -2624,6 +2668,10 @@ function hasDisguisedAssignment(
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.
@@ -2709,6 +2757,7 @@ function hasDisguisedAssignment(
pendingJavaSourceFile = false;
pendingJavaOptionValue = false;
pendingJavaClassPathValue = false;
+ pendingJavaPatchModuleValue = false;
continue;
}
if (pendingBodyName !== null) {
@@ -2997,6 +3046,18 @@ function hasDisguisedAssignment(
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
@@ -3073,7 +3134,10 @@ function hasDisguisedAssignment(
return true;
}
}
- if (pendingJavaClassPathValue) {
+ 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) {
@@ -3088,12 +3152,27 @@ function hasDisguisedAssignment(
// 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)) {
+ } else if (
+ isJavaClassPathOption(unquoted) ||
+ /^(?:-p|--module-path|--upgrade-module-path)$/.test(unquoted)
+ ) {
pendingJavaClassPathValue = true;
- } else if (unquoted.startsWith("--class-path=")) {
+ } else if (/^--(?:class-path|module-path|upgrade-module-path)=/.test(unquoted)) {
if (
javaClassPathPublishesExecutable(
- unquoted.slice("--class-path=".length),
+ 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
)
@@ -3241,6 +3320,13 @@ function hasDisguisedAssignment(
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;
@@ -3265,6 +3351,7 @@ function hasDisguisedAssignment(
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;
@@ -3773,28 +3860,58 @@ interface InheritedLaunchContext {
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 module. */
-function hasInheritedNodeCodeOptions(value: unknown): 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 false;
+ return sharedOpenSslConfig && publishedOpenSslConfig;
+}
+
+function hasInheritedPerlDebuggerOption(value: unknown): boolean {
+ if (typeof value !== "string") return false;
+ return [...value.matchAll(SHELL_WORD)].some((match) =>
+ /^-d(?:$|[:t])/.test(unquoteShellWord(match[0]))
+ );
}
/**
- * JVM option variables inject execution into every launched JVM: an agent
- * archive runs its premain, and a boot-class-path entry supplies executable
- * classes ahead of the application regardless of filename extension.
+ * 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[],
@@ -3802,10 +3919,18 @@ function hasInheritedJavaLaunchOptions(
): 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]);
- // The JVM expands an inherited @argument file into options before
- // parsing, so a published file can inject an agent or boot class path.
+ 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)
@@ -3821,14 +3946,35 @@ function hasInheritedJavaLaunchOptions(
}
const bootClassPath = /^-Xbootclasspath(?:\/[ap])?:(.+)$/.exec(option)?.[1];
if (
- bootClassPath
- ?.split(path.delimiter)
- .some((entry) =>
- isAutoPublishedScriptOperand(canonicalizeInheritedPath(entry), rootPrefixes)
- ) === true
+ 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;
@@ -3954,6 +4100,7 @@ function hasInheritedGitExecutionHook(
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,
@@ -4000,7 +4147,7 @@ function redactMcpConfig(
.map(canonicalizeInheritedPath)
.filter((entry) => isUnderCollectedRoot(entry, rootPrefixes)),
cdPathDirs: (process.env.CDPATH ?? "").split(path.delimiter).map(canonicalizeInheritedPath),
- nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS),
+ nodeCodeOptions: hasInheritedNodeCodeOptions(process.env.NODE_OPTIONS, rootPrefixes),
pythonStartupHook: isAutoPublishedScriptOperand(
canonicalizeInheritedPath(process.env.PYTHONSTARTUP ?? ""),
rootPrefixes
@@ -4031,6 +4178,7 @@ function redactMcpConfig(
rootPrefixes
),
perlDebuggerHook: (process.env.PERL5DB ?? "").trim() !== "",
+ perlDebuggerEnvHook: hasInheritedPerlDebuggerOption(process.env.PERL5OPT),
cmakeToolchainHook: isAutoPublishedScriptOperand(
canonicalizeInheritedPath(process.env.CMAKE_TOOLCHAIN_FILE ?? ""),
rootPrefixes