Skip to content

Commit 0bf9a8b

Browse files
Sergio SisternesCopilot
andcommitted
fix: guard token leakage, header override, and silent failures
- Add .cursor/mcp.json to .gitignore to prevent token commits - Guard registry headers from overriding injected GitHub Authorization - Raise ValueError when no supported package type found instead of silent {} - Add token injection and header-override regression tests - Add CHANGELOG entry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 7db369f commit 0bf9a8b

4 files changed

Lines changed: 87 additions & 0 deletions

File tree

.gitignore

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,5 +72,7 @@ apm_modules/
7272
build/tmp/
7373
scout-pipeline-result.png
7474
.copilot/
75+
# Cursor MCP config (may contain auth tokens)
76+
.cursor/mcp.json
7577
.playwright-mcp/
7678
server.pid

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
2828
- `apm marketplace add` accepts GitLab-class hosts (`gitlab.com` and self-managed instances configured via `GITLAB_HOST` / `APM_GITLAB_HOSTS`); unsupported generic hosts now show separate recovery hints for GHES (`GITHUB_HOST`) and self-managed GitLab instead of only `GITHUB_HOST`. (#1149)
2929
- **GitLab monorepo marketplaces:** `apm install plugin@marketplace` now resolves plugins whose sources live in a subdirectory of the marketplace repository on GitLab-class hosts (`gitlab.com` and self-managed GitLab when classified as GitLab), matching explicit `git:` + `path:` semantics without requiring that hand-written object form. (#1149)
3030
- `apm install` now rejects unsupported flat-format `dependencies` (e.g. `dependencies: [owner/repo]`) with a clear error and structured-format hint instead of silently ignoring them; the resolver also surfaces `ValueError` from malformed transitive manifests as warnings instead of swallowing them. (#1189)
31+
- `apm install --target cursor` now emits Cursor-native MCP schema (`type: stdio` / `type: http`) instead of Copilot-only fields that Cursor silently discards; `.cursor/mcp.json` is gitignored to prevent accidental token commits. (#1240)
3132
- `shared/apm.md` no longer wraps the `target` input in a `|| 'all'` fallback. The defensive expression broke gh-aw's bare-expression substitution regex, causing consumer-supplied `target:` values to be silently dropped; the `import-schema` default already covers the omitted-input case. (#1185)
3233
- `apm install --target all` no longer enumerates the experimental `copilot-cowork` target, which was crashing project-scope installs with a "requires --global" error and made `gh aw` workflows that pin `target: all` unusable. (#1191)
3334
- Stabilized `test_install_over_defer_threshold_starts_live_once` on slow CI runners by joining the deferred-start timer thread instead of relying on a 100ms grace window. (#1191)

src/apm_cli/adapters/client/cursor.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,10 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
176176
header_name = header.get("name", "")
177177
header_value = header.get("value", "")
178178
if header_name and header_value:
179+
# Prevent registry-supplied headers from overriding
180+
# the injected GitHub token
181+
if header_name == "Authorization" and is_github_server:
182+
continue
179183
resolved_value = self._resolve_env_variable(
180184
header_name, header_value, env_overrides
181185
)
@@ -255,6 +259,13 @@ def _format_server_config(self, server_info, env_overrides=None, runtime_vars=No
255259
config["args"] = processed_runtime_args + processed_package_args
256260
if resolved_env:
257261
config["env"] = resolved_env
262+
else:
263+
raise ValueError(
264+
f"No supported package type found for Cursor. "
265+
f"Server: {server_info.get('name', 'unknown')}. "
266+
f"Available packages: "
267+
f"{[p.get('registry_name', 'unknown') for p in packages]}."
268+
)
258269

259270
return config
260271

tests/unit/test_cursor_mcp.py

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,5 +269,78 @@ def test_format_npm_package_emits_type_stdio(self):
269269
self._assert_no_copilot_fields(config)
270270

271271

272+
class TestCursorTokenInjection(unittest.TestCase):
273+
"""Test GitHub token injection for Cursor remote servers."""
274+
275+
def setUp(self):
276+
self.tmp = tempfile.TemporaryDirectory()
277+
self.cursor_dir = Path(self.tmp.name) / ".cursor"
278+
self.cursor_dir.mkdir()
279+
280+
self.adapter = CursorClientAdapter()
281+
self._cwd_patcher = patch("os.getcwd", return_value=self.tmp.name)
282+
self._cwd_patcher.start()
283+
284+
def tearDown(self):
285+
self._cwd_patcher.stop()
286+
self.tmp.cleanup()
287+
288+
def test_github_remote_injects_token(self):
289+
"""Legitimate GitHub remote must get Authorization header."""
290+
server_info = {
291+
"name": "github-mcp-server",
292+
"remotes": [
293+
{"url": "https://api.github.com/v1", "transport_type": "http"},
294+
],
295+
}
296+
with patch("apm_cli.adapters.client.cursor.GitHubTokenManager") as mock_tm:
297+
mock_tm.return_value.get_token_for_purpose.return_value = "test-tok"
298+
config = self.adapter._format_server_config(server_info)
299+
self.assertEqual(config.get("headers", {}).get("Authorization"), "Bearer test-tok")
300+
301+
def test_non_github_remote_no_token(self):
302+
"""Non-GitHub remote must NOT get Authorization header."""
303+
server_info = {
304+
"name": "my-custom-server",
305+
"remotes": [
306+
{"url": "https://evil.example.com/v1", "transport_type": "http"},
307+
],
308+
}
309+
config = self.adapter._format_server_config(server_info)
310+
self.assertNotIn("Authorization", config.get("headers", {}))
311+
312+
def test_registry_header_cannot_override_github_token(self):
313+
"""Registry-supplied Authorization must not clobber injected GitHub token."""
314+
server_info = {
315+
"name": "github-mcp-server",
316+
"remotes": [
317+
{
318+
"url": "https://api.github.com/v1",
319+
"transport_type": "http",
320+
"headers": [
321+
{"name": "Authorization", "value": "Bearer evil-token"},
322+
],
323+
},
324+
],
325+
}
326+
with patch("apm_cli.adapters.client.cursor.GitHubTokenManager") as mock_tm:
327+
mock_tm.return_value.get_token_for_purpose.return_value = "legit-tok"
328+
config = self.adapter._format_server_config(server_info)
329+
self.assertEqual(config["headers"]["Authorization"], "Bearer legit-tok")
330+
331+
def test_unsupported_packages_raises_valueerror(self):
332+
"""When _select_best_package returns None, raise ValueError instead of silent {}."""
333+
server_info = {
334+
"name": "weird-server",
335+
"packages": [
336+
{"registry_name": "unsupported-registry", "name": "pkg"},
337+
],
338+
}
339+
with patch.object(self.adapter, "_select_best_package", return_value=None):
340+
with self.assertRaises(ValueError) as ctx:
341+
self.adapter._format_server_config(server_info)
342+
self.assertIn("No supported package type", str(ctx.exception))
343+
344+
272345
if __name__ == "__main__":
273346
unittest.main()

0 commit comments

Comments
 (0)