fix(copilot): translate env-var placeholders instead of baking secrets to disk (#1152)#1169
Merged
Merged
Conversation
Closes #1152. Copilot CLI mcp-config.json natively supports runtime env-var substitution (${VAR}). The Copilot adapter previously resolved ${env:VAR}, ${VAR}, and legacy <VAR> placeholders to literal values at install time and wrote the resulting plaintext to ~/.copilot/mcp-config.json. Translate to Copilot's native ${VAR} form at install time so secrets stay in the shell. - Add module-level _translate_env_placeholder + helpers - Gate behind class flag _supports_runtime_env_substitution=True on CopilotClientAdapter; sibling adapters (Cursor, Windsurf, OpenCode, Claude, Gemini) opt out and keep current behavior - Translation covers headers, stdio env block, and stdio args - Legacy <VAR> auto-translates with deprecation warning - Per-server install line names referenced env vars (set/not set) - One-time security-improvement notice when overwriting baked literals so users know to export affected vars - 28 unit tests + e2e integration test (Copilot translates, Cursor still resolves to literal as regression trap) - Docs: dependencies.md (env-var placeholders matrix) - CHANGELOG: Security entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
- Route self-defined stdio _raw_stdio env+args through translation
pipeline (was bypassing it; supply-chain finding S1)
- Add negative lookbehind to runtime placeholder regex to avoid
re-matching keys inside already-translated ${VAR} (supply-chain S2)
- Drop redundant per-server install line; integrator tree already
signals success (devx + cli-logging finding)
- Aggregate unset env vars across servers into a single end-of-run
warning with copy-pasteable export hint (devx)
- Promote security-improvement notice from info to warning (devx)
- Add reset_install_run_state hooks in tests for class-state isolation
(test-coverage)
- New TestCopilotInstallRunSummary covers security-upgrade detection,
cross-server unset aggregation, idempotence, and set/unset routing
(test-coverage)
- Drop stale '<VAR> still supported' line in apm-guide skill
- Document Copilot env-var translation contract in cli-commands.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Address round-2 review-panel findings on issue #1152: - Supply-chain BLOCKER: `_resolve_environment_variables` only handled list-of-dicts, but self-defined stdio deps pass a plain dict ({NAME: value}). The dict was iterated as a list, every key failed isinstance(dict), and the result was {} -- silently dropping every env var for self-defined stdio MCP servers. Add dict-input handling that translates placeholder values, replaces literals with runtime ${NAME} placeholders, and tracks both keys and legacy <VAR> offenders. - cli-logging-ux nits: pass symbol="warning" to all _rich_warning calls so the [!] prefix shows up; add blank-line separator before the first warning block via _emit_separator_once helper. - devx-ux nit: replace "(s)" pluralization with proper ternary ("server"/"servers", "variable"/"variables") to match command_logger.py convention. - test-coverage tcx-1152-04: add integration test test_self_defined_stdio_server_translates_env_vars_in_args covering env block + args list with all three syntaxes (${env:VAR}, ${VAR}, legacy <VAR>) and asserting no plaintext leak. - Add unit-tier regression trap class TestCopilotEnvVarTranslationInStdioEnvBlock with three tests pinning dict-input handling so the supply-chain regression cannot return. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
… literals Previously _collect_previously_baked_keys returned a flat set with a '(http header value)' sentinel for any baked header literals it detected. The intersection with _last_env_placeholder_keys (real env-var names) was therefore always empty for header-only upgrades, so users migrating from a config with baked HTTP headers never saw the 'export these vars' notice. Refactor to return (env_keys, headers_were_baked). The configure_mcp_server upgrade-detection branch unions the new placeholder keys when headers_were_baked is True, so the notice now correctly names the vars the user must export. Round-3 review-panel finding. Adds a configure-flow regression test TestCopilotInstallRunSummary::test_security_upgrade_detects_baked_http_header_literals. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates the Copilot MCP adapter to translate env-var placeholders into Copilot CLI runtime-substitution form (instead of resolving them at install time), so secrets are no longer written in plaintext to ~/.copilot/mcp-config.json.
Changes:
- Copilot target now translates
${env:VAR}/${VAR}/ legacy<VAR>into Copilot-native${VAR}without readingos.environ, plus emits an aggregated end-of-run summary for security upgrades, unset vars, and legacy syntax. - Sibling adapters inheriting from
CopilotClientAdapter(Cursor/Windsurf/OpenCode/Claude/Gemini) are explicitly pinned to legacy install-time resolution via_supports_runtime_env_substitution = False. - Adds unit + e2e regression coverage and updates docs + changelog to describe the new Copilot behavior.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/adapters/client/copilot.py |
Implements placeholder translation mode, security-upgrade detection, and aggregated post-install diagnostics. |
src/apm_cli/integration/mcp_integrator.py |
Emits Copilot aggregated install-run summary once per runtime install. |
src/apm_cli/adapters/client/cursor.py |
Pins Cursor to legacy install-time env-var resolution. |
src/apm_cli/adapters/client/windsurf.py |
Pins Windsurf to legacy install-time env-var resolution. |
src/apm_cli/adapters/client/opencode.py |
Pins OpenCode to legacy install-time env-var resolution. |
src/apm_cli/adapters/client/claude.py |
Pins Claude to legacy resolution (no runtime substitution support). |
src/apm_cli/adapters/client/gemini.py |
Pins Gemini to legacy install-time env-var resolution. |
tests/unit/test_copilot_adapter.py |
Expands unit coverage for translation behavior, sibling pinning, and aggregated summary behavior. |
tests/integration/test_mcp_env_var_copilot_e2e.py |
Adds end-to-end guards ensuring Copilot configs contain placeholders (not secrets) and Cursor remains literal. |
docs/src/content/docs/reference/cli-commands.md |
Documents Copilot env-var placeholder translation and aggregated diagnostics. |
docs/src/content/docs/guides/dependencies.md |
Documents placeholder syntax and target-specific materialization behavior. |
packages/apm-guide/.apm/skills/apm-usage/dependencies.md |
Updates dependency authoring guidance to reflect Copilot translation semantics. |
CHANGELOG.md |
Adds an Unreleased Security entry describing the Copilot secrets-on-disk fix. |
Copilot's findings
- Files reviewed: 13/13 changed files
- Comments generated: 4
Comment on lines
+742
to
+764
| if isinstance(env_vars, dict) and self._supports_runtime_env_substitution: | ||
| translated = {} | ||
| placeholder_keys = [] | ||
| for name, raw_value in env_vars.items(): | ||
| if not name: | ||
| continue | ||
| if not isinstance(raw_value, str): | ||
| translated[name] = raw_value | ||
| continue | ||
| if _has_env_placeholder(raw_value): | ||
| self._last_legacy_angle_vars.update(_extract_legacy_angle_vars(raw_value)) | ||
| translated[name] = _translate_env_placeholder(raw_value) | ||
| # Record every ${VAR} in the translated value (handles | ||
| # both ${env:VAR} -> ${VAR} and bare ${VAR} cases). | ||
| for match in _ENV_VAR_RE.finditer(translated[name]): | ||
| placeholder_keys.append(match.group(1)) | ||
| elif name in default_github_env and raw_value == default_github_env[name]: | ||
| translated[name] = raw_value | ||
| else: | ||
| # Literal value present in apm.yml -- replace with a | ||
| # runtime placeholder so the secret never touches disk. | ||
| translated[name] = "${" + name + "}" | ||
| placeholder_keys.append(name) |
Comment on lines
+126
to
+128
| # adapter class so subclasses (Cursor, etc.) maintain their own | ||
| # buckets. Populated by ``configure_mcp_server`` and drained by the | ||
| # post-install summary helper. Class-level so cross-server warnings |
|
|
||
| - **Copilot CLI** (`~/.copilot/mcp-config.json`): the placeholder is preserved as `${VAR}` in the generated config and resolved by Copilot CLI from the host environment at server-start. APM never reads the value, so secrets stay in your shell. Make sure the variable is exported before launching `gh copilot`. | ||
| - **VS Code** (`.vscode/mcp.json`): the placeholder is rewritten to VS Code's `${env:VAR}` form and resolved by VS Code at server-start. | ||
| - **Other harnesses** (Cursor, Windsurf, OpenCode, Claude Desktop, Gemini, Codex): the placeholder is resolved from the current process environment at install time and the literal value is written into the harness config. |
Comment on lines
+412
to
+413
| State is drained after emission so a subsequent install run in | ||
| the same process (e.g. tests) starts clean. |
danielmeppiel
added a commit
that referenced
this pull request
May 6, 2026
- Move [Unreleased] entries into [0.12.3] - 2026-05-06 - Sanitize entries to one concise so-what line per PR - Add #1169 (closes #1152) Copilot env-var placeholder translation - Bump pyproject.toml + uv.lock to 0.12.3 Co-authored-by: Daniel Meppiel <copilot-rework@github.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
fix(copilot): translate env-var placeholders instead of baking secrets to disk (#1152)
TL;DR
apm install --target copilotpreviously resolved env-var placeholders (${env:VAR},${VAR}, legacy<VAR>) at install time and wrote the literal secret values into~/.copilot/mcp-config.json. Copilot CLI natively supports runtime substitution, so this PR translates placeholders to Copilot's native${VAR}form and lets Copilot resolve them at server-start. Secrets no longer touch disk. Sibling adapters (Cursor, Claude, Windsurf, OpenCode, Gemini) are explicitly opted out of the new behavior to keep this PR strictly scoped — they keep their current resolve-time semantics.Important
Closes #1152. After this lands, anyone whose
~/.copilot/mcp-config.jsonwas written by a prior APM version should rotate any secrets that were baked there and re-runapm install --target copilot. APM emits a one-shot security-improvement notice the first time it overwrites baked literals.Problem (WHY)
_resolve_env_variable,_resolve_environment_variables, and_resolve_variable_placeholdersall reados.environand substituted literal values into the config file written to~/.copilot/. A token in${env:GITHUB_TOKEN}landed asBearer ghp_...on disk.${VAR},$VAR,${VAR:-default}— per the Copilot CLI MCP docs. Resolving at install time duplicates the runtime's job and removes the operator's ability to rotate secrets without reinstalling.Cursor,Windsurf,OpenCode,Claude,Gemini) inherit fromCopilotClientAdapter. A naive change to the base methods would have flipped their behavior too — and Claude'sclaude_desktop_config.jsondoes NOT support runtime substitution, so it MUST keep resolving.Why these matter: APM's security model is "secure by default" — secrets are not stored on disk by APM. The previous behavior violated that contract.
Approach (WHAT)
ClassVarflag_supports_runtime_env_substitutiononCopilotClientAdapter(set toTruefor Copilot itself). Five sibling adapters (Cursor,Windsurf,OpenCode,Claude,Gemini) explicitly override the flag toFalseso this PR cannot silently change their semantics.os.environ:${env:VAR}->${VAR},${VAR}->${VAR},<VAR>->${VAR}(with deprecation warning).${VAR:-default},${input:foo},$VAR,${{ secrets.X }}pass through unchanged.envblock (both list-of-dicts AND dict shapes), and stdioargs. APM template vars like{runtime_var}still resolve at install time (they're an APM concept, not env vars).<VAR>deprecation warnings - all keyed on names only, never values.Implementation (HOW)
src/apm_cli/adapters/client/copilot.py- Adds module-level helpers_translate_env_placeholder,_has_env_placeholder,_extract_legacy_angle_vars.CopilotClientAdaptersets_supports_runtime_env_substitution = Trueplus fourClassVaraggregation buckets (_legacy_angle_offenders_by_server,_security_upgraded_keys,_unset_env_keys_by_server,_install_run_summary_emitted). Refactors_resolve_env_variable,_resolve_environment_variables(now handles dict-shaped stdio env blocks too), and_resolve_variable_placeholdersto branch on the flag. Adds_emit_install_summary, classmethodemit_install_run_summary, andreset_install_run_state._collect_previously_baked_keysnow returns(env_keys, headers_were_baked)so the security-upgrade detector picks up the case where the only baked literals were HTTP headers (which don't expose env-var names directly).src/apm_cli/adapters/client/{cursor,windsurf,opencode,claude,gemini}.py— Each sibling explicitly sets_supports_runtime_env_substitution: ClassVar[bool] = False. This freezes their existing behavior so this PR cannot silently change them. Per-adapter migration to the runtime-translation contract becomes a follow-up issue per adapter (each needs its own runtime-substitution research).src/apm_cli/integration/mcp_integrator.py— CallsCopilotClientAdapter.emit_install_run_summary()once after the per-server install loop, gated onruntime == "copilot". The aggregated summary appears between the per-server tree lines and the+- Configured N server(s)close.tests/unit/test_copilot_adapter.py— 39 unit tests across five classes: translation table, header path, stdio env-block dict shape (regression trap for the supply-chain finding), inheritance flag (siblings unchanged), and post-install summary aggregation. Each class hassetUp/tearDownthat callsreset_install_run_state()for isolation.tests/integration/test_mcp_env_var_copilot_e2e.py— Three end-to-end tests run realapm install --target copilotsubprocesses with secrets exported to the environment, then assert the resulting~/.copilot/mcp-config.jsoncontains${VAR}and not the secret literal: HTTP server, self-defined stdio (env + args), and a Cursor regression trap that confirms the sibling-adapter still resolves at install time.docs/src/content/docs/guides/dependencies.md,docs/src/content/docs/reference/cli-commands.md,packages/apm-guide/.apm/skills/apm-usage/dependencies.md,CHANGELOG.md— Document the translation contract, post-install diagnostics, and the security-improvement entry.Diagrams
Legend: how the same
apm.ymlenv-var placeholder flowed onto disk before vs after this PR. Highlighted region shows the new branch where APM stops reading the host environment.sequenceDiagram participant U as User shell participant A as apm install participant E as os.environ participant F as ~/.copilot/mcp-config.json participant C as gh copilot Note over U,C: BEFORE this PR U->>A: apm install --target copilot A->>E: read GITHUB_TOKEN E-->>A: ghp_secret_value A->>F: write Bearer ghp_secret_value C->>F: read config at server-start F-->>C: Bearer ghp_secret_value rect rgb(255, 247, 200) Note over U,C: AFTER this PR U->>A: apm install --target copilot A->>F: write Bearer ${GITHUB_TOKEN} Note over A,E: APM never reads os.environ C->>F: read config at server-start F-->>C: Bearer ${GITHUB_TOKEN} C->>E: substitute ${GITHUB_TOKEN} E-->>C: ghp_secret_value endLegend: which adapters opt in to the new translation behavior. Five siblings inherit from
CopilotClientAdapterand explicitly opt out so this PR cannot silently change their semantics.flowchart LR subgraph base[Base] MCP[MCPClientAdapter] end subgraph translate[Translate at install time] COP[CopilotClientAdapter<br/>_supports_runtime = True] end subgraph resolve[Resolve at install time, unchanged] CUR[CursorClientAdapter] WIN[WindsurfClientAdapter] OPE[OpenCodeClientAdapter] CLA[ClaudeClientAdapter] GEM[GeminiClientAdapter] end MCP --> COP MCP --> CDX[CodexClientAdapter<br/>not affected] COP --> CUR COP --> WIN COP --> OPE COP --> CLA COP --> GEM classDef new stroke-dasharray: 5 5,stroke:#0a7; class COP new;Trade-offs
${VAR}in their config and must export the var beforegh copilot.claude_desktop_config.jsonhas no native interpolation). Follow-up issues per adapter are the right venue.is_github_serverliteral token baking left in place. The remote-GitHub branch atcopilot.py:533-541still reads a live token fromGitHubTokenManagerand writesBearer {token}to disk. That predates this PR and is out of scope; supply-chain review explicitly flagged it as nit-not-blocker. Tracked as follow-up.reset_install_run_stateonly called from tests today. Long-lived processes that invoke install programmatically would accumulate env-var names (never values) in class-level state. Acceptable for the CLI's actual usage; documented as a follow-up.Benefits
~/.copilot/mcp-config.jsonfor the env-var path — verified bygrep -Fover the file in the e2e test.export VAR=...and re-rungh copilot; noapm installre-run required.<VAR>deprecation, all in one place at end-of-run.tests/unit/test_copilot_adapter.py::TestCopilotInheritedAdaptersUnchangedmakes silent regression on Cursor/Claude/etc. impossible.<VAR>migration path — translated automatically with a one-shot deprecation warning naming the offending servers, scheduled for removal in v1.0.Validation
Lint (canonical CI-mirror commands per
.apm/instructions/linting.instructions.md):Tests:
Cross-adapter regression check (all 211 inherited adapter tests still green):
Smoke test: real
apm install --target copilotwith secrets exported, confirming no plaintext leakScenario Evidence
apm install --target copilotdoes NOT bake secret values into~/.copilot/mcp-config.jsonfor HTTP servers; runtime placeholders appear insteadtests/integration/test_mcp_env_var_copilot_e2e.py::TestMcpEnvVarHeadersCopilot::test_self_defined_http_server_translates_env_vars_not_resolves(regression-trap for #1152)apm install --target copilotdoes NOT bake secret values for self-defined stdio servers (env block AND args), across${env:VAR},${VAR}, and legacy<VAR>syntaxestests/integration/test_mcp_env_var_copilot_e2e.py::TestMcpEnvVarHeadersCopilot::test_self_defined_stdio_server_translates_env_vars_in_args(regression-trap for the supply-chain dict-shape bug)apm install --target cursorcontinues to RESOLVE env vars at install time (sibling adapters are not silently affected)tests/integration/test_mcp_env_var_copilot_e2e.py::TestMcpEnvVarHeadersCursor::test_cursor_still_resolves_env_vars_to_literal<VAR>syntax inapm.ymlis translated to${VAR}for Copilot AND a deprecation warning names the offending serverstests/unit/test_copilot_adapter.py::TestCopilotEnvVarTranslationInHeaders::test_legacy_angle_translates_with_warningexporttests/unit/test_copilot_adapter.py::TestCopilotInstallRunSummary::test_security_upgrade_warning_when_baked_keys_detectedenvblock is not silently dropped to{}(regression trap for round-2 supply-chain finding)tests/unit/test_copilot_adapter.py::TestCopilotEnvVarTranslationInStdioEnvBlock::test_dict_shaped_env_block_does_not_silently_droptests/unit/test_copilot_adapter.py::TestCopilotInstallRunSummary::test_security_upgrade_detects_baked_http_header_literalsHow to test
uv sync --extra devin a clean checkout.uv run --extra dev pytest tests/integration/test_mcp_env_var_copilot_e2e.py -vand confirm 3 passed.apm.ymlwith one self-defined stdio MCP server using${env:MY_TOKEN}inargs, thenMY_TOKEN=plaintext APM_NON_INTERACTIVE=1 apm install --target copilotwithHOME=/tmp/scratch. Confirm/tmp/scratch/.copilot/mcp-config.jsoncontains${MY_TOKEN}and does NOT containplaintext.apm install --target cursorinstead. Confirm~/.cursor/mcp.json(or equivalent) still contains the resolved literal — sibling adapters are unaffected by this PR.[!] Copilot CLI will resolve N environment variable(s) at runtime that are not currently setif any referenced vars are missing from the shell.Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com