feat(report-issue): bundle sanitized data.json in the issue report

The Report an Issue bundle now includes a copy of the plugin's persisted
settings (data.json), which maintainers often need to reproduce
configuration-dependent bugs. The copy is sanitized before it is written:
API keys, license keys, tokens, secrets, infrastructure identifiers,
userId, every envOverrides value, and legacy enc_* values are masked as
[REDACTED], so which providers are configured stays visible while the
values never leave the machine. The report dialog, settings description,
and docs now surface that the settings copy is included and redacted.
Saved as data.json.txt for GitHub's attachment allowlist, mirroring the
frame-log workaround.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W4ZjSKUSbWyWjx4CLeJS5v
This commit is contained in:
Claude 2026-07-12 15:05:50 +00:00
parent 29254f6f16
commit 2a26ac52ff
No known key found for this signature in database
7 changed files with 347 additions and 8 deletions

View file

@ -191,12 +191,13 @@ Clicking it opens a short form where you describe what happened. When you submit
1. Saves a screenshot of the **Agent Mode chat pane** (just the agent panel, not your whole screen).
2. Saves a recent **Agent Mode log** of the behind-the-scenes messages between Copilot and the agent. This log is captured automatically so a report always has recent activity to include; you can turn it off under **Settings → Copilot → Advanced → Keep an Agent Mode activity log**.
3. Opens the folder containing those files in your file manager.
4. Opens a prefilled GitHub issue in your browser.
3. Saves a copy of your **Copilot settings** (the plugin's `data.json`), which helps maintainers reproduce configuration-dependent problems. All secrets — API keys, license keys, tokens, and backend environment variable values — are automatically replaced with `[REDACTED]` before the copy is written.
4. Opens the folder containing those files in your file manager.
5. Opens a prefilled GitHub issue in your browser.
The files are **not uploaded for you** — drag them from the opened folder into the GitHub issue to attach them.
> **Privacy note:** The Agent Mode log can contain your prompts, note contents, and tool inputs/outputs in plaintext. Review the saved files before sharing them publicly.
> **Privacy note:** The Agent Mode log can contain your prompts, note contents, and tool inputs/outputs in plaintext, and the settings copy still describes your configuration (model names, folder names, feature toggles) even though secrets are redacted. Review the saved files before sharing them publicly.
### Including the OpenCode log

View file

@ -7,6 +7,7 @@ import { captureViewScreenshot } from "@/utils/captureViewScreenshot";
import { isDesktopRuntime } from "@/utils/desktopRuntime";
import { assembleReportBundle, type ReportEnvInfo } from "@/utils/issueReport";
import { findLatestOpencodeLog } from "@/utils/opencodeLog";
import { sanitizeSettingsDataForReport } from "@/utils/sanitizeSettingsData";
import { createPluginRoot } from "@/utils/react/createPluginRoot";
import { cn } from "@/lib/utils";
import { getSettings } from "@/settings/model";
@ -30,6 +31,13 @@ export interface ReportIssueModalParams {
activeBackend: string;
/** Plugin version for the report's environment block. */
pluginVersion: string;
/**
* Loads the raw persisted plugin settings (the on-disk `data.json`, e.g. via
* `Plugin.loadData()`). The modal sanitizes the result (masks API keys,
* license keys, and other secrets) before bundling it into the report.
* Returns null when the data cannot be loaded; the report then skips it.
*/
loadSettingsData: () => Promise<Record<string, unknown> | null>;
}
interface ElectronShell {
@ -119,6 +127,11 @@ function ReportIssueContent({ showOpencodeOption, onSubmit, onCancel }: ReportIs
(not your whole screen) and a recent activity log are saved to a folder on your
computer.
</li>
<li>
A copy of your <strong className="tw-text-normal">Copilot settings</strong> (data.json)
is included, with API keys, license keys, and other secrets replaced by{" "}
<code>[REDACTED]</code>.
</li>
<li>That folder opens, and a pre-filled GitHub issue opens in your browser.</li>
<li>Drag the saved files into the issue to attach them, then submit.</li>
</ul>
@ -136,8 +149,9 @@ function ReportIssueContent({ showOpencodeOption, onSubmit, onCancel }: ReportIs
<span className="tw-block tw-font-semibold">Before you share these files</span>
<span className="tw-mt-0.5 tw-block tw-text-normal">
The activity log can include your prompts, note contents, and tool inputs/outputs in
plain text. Review the saved files and remove anything sensitive before posting them
publicly.
plain text. The settings copy has API keys and other secrets automatically redacted, but
still describes your configuration. Review the saved files and remove anything sensitive
before posting them publicly.
</span>
</div>
</div>
@ -226,6 +240,8 @@ export class ReportIssueModal extends Modal {
? await resolveOpencodeLogPath()
: null;
const settingsJson = await this.buildSanitizedSettingsJson();
const env: ReportEnvInfo = {
pluginVersion: this.params.pluginVersion,
platform: process.platform,
@ -239,6 +255,7 @@ export class ReportIssueModal extends Modal {
screenshotPng,
frameLogPath,
opencodeLogPath,
settingsJson,
reportsRootDir: root,
timestamp: formatTimestamp(new Date()),
});
@ -254,6 +271,22 @@ export class ReportIssueModal extends Modal {
new Notice("Failed to prepare the issue report. See the console for details.");
}
}
/**
* Load the raw persisted settings and return a pretty-printed, sanitized
* JSON string for the bundle, or null when loading fails (the report is
* still produced without it).
*/
private async buildSanitizedSettingsJson(): Promise<string | null> {
try {
const raw = await this.params.loadSettingsData();
if (!raw) return null;
return JSON.stringify(sanitizeSettingsDataForReport(raw), null, 2);
} catch (err) {
logError("[ReportIssue] failed to load settings for the report:", err);
return null;
}
}
}
async function resolveOpencodeLogPath(): Promise<string | null> {

View file

@ -156,6 +156,7 @@ export const AdvancedSettings: React.FC = () => {
getPlugin: (id: string) => {
manifest?: { version?: string };
agentSessionManager?: { getActiveSession?: () => { backendId?: string } | null };
loadData?: () => Promise<unknown>;
} | null;
};
}
@ -170,6 +171,13 @@ export const AdvancedSettings: React.FC = () => {
app,
activeBackend,
pluginVersion: copilotPlugin?.manifest?.version ?? "unknown",
// Raw loadData() is the true on-disk data.json (the modal sanitizes
// it), so the report reflects what is actually persisted rather than
// the in-memory settings object.
loadSettingsData: async () => {
const raw = await copilotPlugin?.loadData?.();
return raw && typeof raw === "object" ? (raw as Record<string, unknown>) : null;
},
// Resolve at capture time so we can close this Settings window and
// reveal the agent pane first — the screenshot should be the chat
// surface, not the settings dialog. Null when no agent pane is open.
@ -531,7 +539,7 @@ export const AdvancedSettings: React.FC = () => {
<SettingItem
type="custom"
title="Report an Issue"
description="Bundles a screenshot of the Agent Mode chat pane and a recent activity log into a folder, then opens a prefilled GitHub issue for you to attach them to."
description="Bundles a screenshot of the Agent Mode chat pane, a recent activity log, and a copy of your Copilot settings (with API keys and other secrets redacted) into a folder, then opens a prefilled GitHub issue for you to attach them to."
>
<Button variant="secondary" size="sm" onClick={handleReportIssue}>
Report an Issue

View file

@ -37,6 +37,7 @@ const baseInput: ReportInput = {
screenshotPng: new Uint8Array([1, 2, 3]),
frameLogPath: "/tmp/acp-frames.ndjson",
opencodeLogPath: "/tmp/opencode/log/session.log",
settingsJson: '{\n "openAIApiKey": "[REDACTED]"\n}',
reportsRootDir: "/tmp/reports",
timestamp: "20260615-101500",
};
@ -54,12 +55,16 @@ describe("assembleReportBundle", () => {
// Bundled with a `.txt` suffix so GitHub accepts the upload (it rejects `.ndjson`).
"acp-frames.ndjson.txt",
"opencode.log",
// Same `.txt` allowlist workaround as the frame log.
"data.json.txt",
]);
expect(writes.map((w) => w.path)).toContain(
"/tmp/reports/report-20260615-101500/screenshot.png"
);
expect(writes.map((w) => w.path)).toContain("/tmp/reports/report-20260615-101500/report.md");
const settingsWrite = writes.find((w) => w.path.endsWith("data.json.txt"));
expect(settingsWrite?.data).toBe(baseInput.settingsJson);
expect(copies).toEqual([
{
src: "/tmp/acp-frames.ndjson",
@ -105,10 +110,38 @@ describe("assembleReportBundle", () => {
expect(result.files).toContain("opencode.log");
});
it("omits the settings snapshot when it could not be loaded", async () => {
const { runtime, writes } = makeRuntime();
const result = await assembleReportBundle({ ...baseInput, settingsJson: null }, runtime);
expect(result.files).not.toContain("data.json.txt");
expect(writes.map((w) => w.path)).not.toContain(
"/tmp/reports/report-20260615-101500/data.json.txt"
);
});
it("skips a settings snapshot that fails to write instead of failing the whole report", async () => {
const { runtime } = makeRuntime({
writeFile: async (p) => {
if (p.endsWith("data.json.txt")) throw new Error("EACCES");
},
});
const result = await assembleReportBundle(baseInput, runtime);
expect(result.files).toContain("report.md");
expect(result.files).not.toContain("data.json.txt");
});
it("always writes report.md even when nothing else is captured", async () => {
const { runtime, writes } = makeRuntime();
const result = await assembleReportBundle(
{ ...baseInput, screenshotPng: null, frameLogPath: null, opencodeLogPath: null },
{
...baseInput,
screenshotPng: null,
frameLogPath: null,
opencodeLogPath: null,
settingsJson: null,
},
runtime
);
@ -130,6 +163,11 @@ describe("buildReportMarkdown", () => {
expect(md).toContain("- screenshot.png");
});
it("annotates the settings snapshot so readers know secrets were redacted", () => {
const md = buildReportMarkdown(baseInput, ["report.md", "data.json.txt"]);
expect(md).toContain("- data.json.txt (plugin settings; secrets redacted)");
});
it("falls back to a placeholder when the note is empty", () => {
const md = buildReportMarkdown({ ...baseInput, note: " " }, []);
expect(md).toContain("_No description provided._");

View file

@ -22,6 +22,10 @@ const SCREENSHOT_NAME = "screenshot.png";
const FRAME_LOG_NAME = "acp-frames.ndjson.txt";
const OPENCODE_LOG_NAME = "opencode.log";
const REPORT_NOTE_NAME = "report.md";
// Sanitized copy of the plugin settings (data.json with secrets masked).
// Same allowlist workaround as the frame log: `.json` isn't accepted by
// GitHub's issue attachments, so the file gets a trailing `.txt`.
const SETTINGS_JSON_NAME = "data.json.txt";
export interface ReportEnvInfo {
pluginVersion: string;
@ -40,6 +44,11 @@ export interface ReportInput {
frameLogPath: string | null;
/** Absolute path to the latest opencode log, included only when provided. */
opencodeLogPath: string | null;
/**
* Pretty-printed plugin settings JSON, already sanitized (secrets masked)
* by the caller, or null when the settings could not be loaded.
*/
settingsJson: string | null;
/** Root dir bundles are written under (one timestamped subfolder per report). */
reportsRootDir: string;
/** Pre-formatted timestamp (e.g. `20260615-101500`) used for the subfolder. */
@ -104,6 +113,15 @@ export async function assembleReportBundle(
}
}
if (input.settingsJson) {
try {
await runtime.writeFile(runtime.join(folderPath, SETTINGS_JSON_NAME), input.settingsJson);
files.push(SETTINGS_JSON_NAME);
} catch {
// Settings snapshot is optional; keep going without it.
}
}
const noteMarkdown = buildReportMarkdown(input, files);
await runtime.writeFile(runtime.join(folderPath, REPORT_NOTE_NAME), noteMarkdown);
files.unshift(REPORT_NOTE_NAME);
@ -133,7 +151,9 @@ export function buildReportMarkdown(input: ReportInput, attachedFiles: string[])
"",
"## Attached files",
"",
...attachments.map((f) => `- ${f}`),
...attachments.map((f) =>
f === SETTINGS_JSON_NAME ? `- ${f} (plugin settings; secrets redacted)` : `- ${f}`
),
"",
"> These files were saved to the bundle folder that just opened. Drag them",
"> into the GitHub issue to attach them.",

View file

@ -0,0 +1,144 @@
import { REDACTED_VALUE, sanitizeSettingsDataForReport } from "@/utils/sanitizeSettingsData";
/**
* A realistic disk-mode `data.json` fixture: plaintext secrets at the top
* level, per-model secrets inside the model arrays, env-var overrides in the
* agent backend settings, and a legacy encrypted value.
*/
const SECRETS = {
openAIApiKey: "sk-proj-abc123",
plusLicenseKey: "plus-license-xyz",
githubCopilotAccessToken: "gho_accesstoken456",
modelApiKey: "sk-model-key-789",
embeddingApiKey: "sk-embed-key-000",
awsAccessKeyId: "AKIAIOSFODNN7EXAMPLE",
awsSecretEnvValue: "wJalrXUtnFEMI/K7MDENG",
legacyEncrypted: "enc_desk_QUJDREVGR0g=",
userId: "user-12345",
azureInstance: "my-private-instance",
} as const;
const rawFixture = {
userId: SECRETS.userId,
openAIApiKey: SECRETS.openAIApiKey,
plusLicenseKey: SECRETS.plusLicenseKey,
githubCopilotAccessToken: SECRETS.githubCopilotAccessToken,
anthropicApiKey: "",
azureOpenAIApiInstanceName: SECRETS.azureInstance,
defaultModelKey: "gpt-4o|openai",
temperature: 0.7,
debug: false,
activeModels: [
{
name: "gpt-4o",
provider: "openai",
apiKey: SECRETS.modelApiKey,
baseUrl: "https://api.example.com/v1",
enabled: true,
},
{ name: "claude-sonnet", provider: "anthropic", apiKey: "" },
],
activeEmbeddingModels: [
{ name: "text-embedding-3-small", provider: "openai", apiKey: SECRETS.embeddingApiKey },
],
googleApiKey: SECRETS.legacyEncrypted,
agentMode: {
activeBackend: "claude",
backends: {
claude: {
envOverrides: {
AWS_ACCESS_KEY_ID: SECRETS.awsAccessKeyId,
AWS_SECRET_ACCESS_KEY: SECRETS.awsSecretEnvValue,
CLAUDE_CONFIG_DIR: "/home/user/.claude",
EMPTY: "",
},
},
},
deviceProfiles: {
"device-a": {
codex: { binaryPath: "/usr/local/bin/codex", envOverrides: { FOO: "bar" } },
},
},
},
};
describe("sanitizeSettingsDataForReport", () => {
// The sanitizer preserves structure, so the fixture's own type describes the output.
const sanitized = sanitizeSettingsDataForReport(rawFixture) as typeof rawFixture;
it("never leaks any secret value anywhere in the serialized output", () => {
const serialized = JSON.stringify(sanitized);
for (const [label, secret] of Object.entries(SECRETS)) {
expect({ label, leaked: serialized.includes(secret) }).toEqual({ label, leaked: false });
}
});
it("masks top-level API keys, license keys, and tokens", () => {
expect(sanitized.openAIApiKey).toBe(REDACTED_VALUE);
expect(sanitized.plusLicenseKey).toBe(REDACTED_VALUE);
expect(sanitized.githubCopilotAccessToken).toBe(REDACTED_VALUE);
});
it("masks per-model apiKey inside model arrays", () => {
expect(sanitized.activeModels[0].apiKey).toBe(REDACTED_VALUE);
expect(sanitized.activeEmbeddingModels[0].apiKey).toBe(REDACTED_VALUE);
});
it("masks every non-empty envOverrides value at any depth, keeping key names", () => {
const claudeEnv = sanitized.agentMode.backends.claude.envOverrides;
expect(claudeEnv).toEqual({
AWS_ACCESS_KEY_ID: REDACTED_VALUE,
AWS_SECRET_ACCESS_KEY: REDACTED_VALUE,
CLAUDE_CONFIG_DIR: REDACTED_VALUE,
EMPTY: "",
});
expect(sanitized.agentMode.deviceProfiles["device-a"].codex.envOverrides).toEqual({
FOO: REDACTED_VALUE,
});
});
it("masks infrastructure identifiers and userId", () => {
expect(sanitized.azureOpenAIApiInstanceName).toBe(REDACTED_VALUE);
expect(sanitized.userId).toBe(REDACTED_VALUE);
});
it("masks legacy enc_* values regardless of the key they live under", () => {
expect(sanitized.googleApiKey).toBe(REDACTED_VALUE);
const elsewhere = sanitizeSettingsDataForReport({
someHarmlessField: SECRETS.legacyEncrypted,
}) as Record<string, unknown>;
expect(elsewhere.someHarmlessField).toBe(REDACTED_VALUE);
});
it("leaves empty sensitive values as-is so 'not configured' stays visible", () => {
expect(sanitized.anthropicApiKey).toBe("");
expect(sanitized.activeModels[1].apiKey).toBe("");
});
it("passes non-sensitive fields through unchanged", () => {
expect(sanitized.defaultModelKey).toBe("gpt-4o|openai");
expect(sanitized.temperature).toBe(0.7);
expect(sanitized.debug).toBe(false);
expect(sanitized.activeModels[0].name).toBe("gpt-4o");
expect(sanitized.activeModels[0].baseUrl).toBe("https://api.example.com/v1");
expect(sanitized.activeModels[0].enabled).toBe(true);
expect(sanitized.agentMode.activeBackend).toBe("claude");
expect(sanitized.agentMode.deviceProfiles["device-a"].codex.binaryPath).toBe(
"/usr/local/bin/codex"
);
});
it("does not mutate the input object", () => {
const raw = { openAIApiKey: "sk-live" };
sanitizeSettingsDataForReport(raw);
expect(raw.openAIApiKey).toBe("sk-live");
});
it("handles non-object inputs gracefully", () => {
expect(sanitizeSettingsDataForReport(null)).toBeNull();
expect(sanitizeSettingsDataForReport(undefined)).toBeUndefined();
expect(sanitizeSettingsDataForReport("plain")).toBe("plain");
expect(sanitizeSettingsDataForReport(42)).toBe(42);
expect(sanitizeSettingsDataForReport([1, "a"])).toEqual([1, "a"]);
});
});

View file

@ -0,0 +1,95 @@
/**
* Sanitizes a raw settings object (the on-disk `data.json`) so it can be
* shared in a public bug report. Sensitive values are masked with a
* placeholder rather than removed, so maintainers can still see which
* providers/keys are configured without seeing the values.
*
* Leaf module by design: it receives the raw data as a plain object and has
* no settings/singleton dependencies, so it is directly unit-testable.
*/
import { hasEncryptionPrefix, isSensitiveKey } from "@/encryptionService";
/** Placeholder that replaces every sensitive value in the shared copy. */
export const REDACTED_VALUE = "[REDACTED]";
/**
* Infrastructure identifiers that could leak deployment details (Azure
* instance/deployment names, org IDs). Mirrors the redaction set used by
* `LogFileManager` for the copilot log file.
*/
const INFRA_KEY_PATTERNS = [/orgId$/i, /instanceName$/i, /deploymentName$/i, /apiVersion$/i];
/**
* Env-var override maps (agent backend settings) can carry arbitrary
* credentials whose names don't match any key pattern (e.g.
* AWS_ACCESS_KEY_ID), so every value inside them is masked wholesale.
*/
const ENV_OVERRIDES_KEY = "envOverrides";
/**
* Check whether a key should have its value masked in the shared copy.
*/
function isRedactedKey(key: string): boolean {
if (isSensitiveKey(key)) return true;
if (key === "userId") return true;
if (key.startsWith("enc_")) return true;
return INFRA_KEY_PATTERNS.some((pattern) => pattern.test(key));
}
/**
* Mask a single value: empty strings, null, and undefined pass through so
* "not configured" remains visible; everything else becomes the placeholder.
*/
function maskValue(value: unknown): unknown {
if (value === null || value === undefined || value === "") return value;
return REDACTED_VALUE;
}
/**
* Recursively clone a raw settings object, masking the values of sensitive
* keys (API keys, license keys, tokens, secrets, passwords), infrastructure
* identifiers, `userId`, every entry of any `envOverrides` map, and any
* string still carrying a legacy `enc_*` encryption prefix.
*
* @param raw - The raw data as loaded from disk (e.g. `plugin.loadData()`).
* @returns A sanitized deep copy safe to include in a public bug report.
*/
export function sanitizeSettingsDataForReport(raw: unknown): unknown {
if (raw === null || raw === undefined) return raw;
if (typeof raw === "string") {
// Legacy encrypted secrets are still secrets (the key may live on this
// device); mask them wherever they appear, regardless of key name.
return hasEncryptionPrefix(raw) ? REDACTED_VALUE : raw;
}
if (Array.isArray(raw)) {
return raw.map((item) => sanitizeSettingsDataForReport(item));
}
if (typeof raw === "object") {
const result: Record<string, unknown> = {};
for (const [key, value] of Object.entries(raw as Record<string, unknown>)) {
if (
key === ENV_OVERRIDES_KEY &&
value &&
typeof value === "object" &&
!Array.isArray(value)
) {
const masked: Record<string, unknown> = {};
for (const [envKey, envValue] of Object.entries(value as Record<string, unknown>)) {
masked[envKey] = maskValue(envValue);
}
result[key] = masked;
} else if (isRedactedKey(key)) {
result[key] = maskValue(value);
} else {
result[key] = sanitizeSettingsDataForReport(value);
}
}
return result;
}
return raw;
}