跳到正文
北京时间
原文
Hacker News 热门(buzzing.cc 中文翻译)· j0selit0·· 2026-08-12精选AI 评分71

将 GitHub Copilot 置于中间人(MitM)代理之后后,我学到了什么

AI 导读

作者通过 mitmproxy 对 VS Code 中的 GitHub Copilot 进行中间人代理拦截,逆向分析其网络流量与内部架构。文章指出这些 AI 应用普遍基于 Electron 构建,共享相似的网络栈,因此探测结果可迁移至其他同类应用。作者借此揭示了 Copilot 的运行时行为,并分享了配置代理的具体步骤。

推荐理由

通过抓包与源码分析,文章展示 Copilot 如何把 .env 内容传至 API 并明文存储历史,阐释 AI 编码工具成为有状态系统后上下文既是产品也是风险源。

正文 · AI 翻译

大家好,Rafael在此——每周我都会以一名构建 AI 系统的工程师视角,介绍我近期遇到的有趣挑战和进展。

过去几年里,AI 驱动的应用和 AI 功能层出不穷。像Slack这样的老牌玩家也迅速在其产品阵容中加入了 AI 功能。而对于像Cursor、Notion、ChatGPT Desktop和Claude Desktop这样的 AI 原生应用来说,AI 一直是其存在的根本理由。

这些应用发布的 AI 功能越多,我就越想探究它们的内部运作机制。但愿我能揭开一点它们底层运行的秘密;至少,我也能学到一两件关于桌面应用开发的事。

巧合的是,我注意到自己每个月耗尽 Copilot 额度的速度越来越早。这最终促使我为自己的实验选定了一个主要候选对象。我决定深入研究VS Code 和 Copilot。

一个共同点:Electron

上述所有应用的共同点在于,它们都是使用 Electron 构建的。Electron 是一个 JavaScript 框架,帮助开发者构建和分发桌面应用。通俗地说,它的工作原理是将 Node.js 运行时与 HTML、CSS 和 JavaScript 产物打包在一起,然后通过 Chromium 进行渲染。

使用 Electron 的应用列表。我知道,很多。来源

这消除了为不同平台维护多套原生语言代码库的需要(例如,Windows 用 C#,macOS 用 Swift),使开发者更容易从单一代码库构建跨多个平台运行的桌面应用。(原生模块和某些打包步骤通常仍需要按平台分别处理,但大部分应用逻辑是共享的。)

由于它们共享 Electron,它们也共享大致相同的架构,这意味着我在探究其中一个应用时学到的东西应该可以迁移到其他应用上。

先看网络数据包,再看源码

我的第一反应是直接浏览 VS Code 源码,看看能否回答我的问题。问题在于,我当时还没有一套完整的问题——而在数百万行代码中搜寻这些问题,要么会耗费我太多时间,要么会消耗太多 token。

源代码会告诉你一个应用能做什么;而发现它在运行时实际做了什么则更具挑战性。尤其是当你还不知道自己在找什么的时候。

还有第二个问题。VS Code 是我最初着手的那些应用中的一个例外:它的源代码(至少大部分)是开放的。而 Claude、ChatGPT、Codex、Notion 以及 Slack 则并非如此。

这开始把我推向逆向工程路线:先被动地观察流量,让请求和响应告诉我哪些问题值得追问,然后才去查看源代码来确认(或推翻)我所看到的东西。

这意味着我得亲自钻研 Electron 的架构和网络栈——这些技能日后掌握也不吃亏。

Electron 的网络架构

到现在我们都知道了,Electron 应用自带 Chromium。浏览器为应用的基于 Web 的 UI 提供渲染引擎,但它同时也提供了一个网络栈,供渲染进程用于 HTTP 和 WebSocket 连接。

这是让应用与远程后端通信的一种常见(也是被推荐的)方案,但它并非唯一方案。应用也可以使用 Node 的 http/https/fetch 来发起 HTTP 请求。当你试图拦截请求时,请求走哪条路径就变得很重要了。

在某些情况下,比如 VS Code,应用程序会采用解耦架构,其中有一个独立的一组进程充当扩展宿主。这有助于在不同职责之间维持清晰的边界;就 VS Code 而言,就是 UI、代码 IDE 功能和插件/扩展之间的清晰边界。

VS Code 架构。来源:VS Code 文档

检查 Electron 应用的网络流量

拦截应用程序网络流量的经典方法之一是搭建一个代理服务器,并将该应用程序配置为使用它。

代理充当中间人(MITM):它拦截来自客户端的 HTTP 请求,将其转发给服务器,并将服务器响应中继回客户端。

有趣的是:类似的方法在企业网络环境中用于流量检查相当常见,尤其是在高度受监管的行业。恰如其分的是,用于此目的的主要开源工具之一就叫 mitmproxy,我们将在接下来的步骤中使用它。

一个重要细节是,如今大多数网络流量都通过安全 HTTP(HTTPS)进行。这意味着流量使用 TLS 加密。

通过信任 mitmproxy 本地生成的证书颁发机构(CA),客户端就能接受 mitmproxy 为每个目标地址即时生成的证书。这样一来,你得到的不是一条端到端加密连接,而是两条:一条位于应用程序与 mitmproxy 之间,另一条位于 mitmproxy 与目标服务器之间。

因此,mitmproxy 可以解密该请求、对其进行检查、向上游建立一条独立的 TLS 连接,并将响应转发回应用程序。

快速上手

如果你不想跟着代码一步步操作,只想看看结果,可以放心跳过本节。

安装 mitmproxy

在 macOS 上,最简单的方式是使用 brew:

brew install mitmproxy

VS Code 配置

我们需要修改 VS Code 中的一些设置,让其流量经由 mitmproxy 转发。你可以使用快捷键组合 Cmd+Shift+P 并搜索 User Settings。然后,你需要确保以下设置具有如下取值:

  • Http Proxy:http://localhost:8080(mitmproxy 将在此端口监听连接)

  • Http Proxy Strict SSL:未勾选(我们希望跳过将 mitmproxy 的证书与一组 CA 进行验证)

  • Http: Proxy Support:override(强制为扩展启用代理支持)

用于将网络流量路由到 mitmproxy 的 VS Code 设置

完成这些更改后,请务必重启 VS Code。

mitm web UI

开始之前的最后一步是启动 mitmproxy 的 web UI:

mitmweb

稍等几秒,你应该就会开始看到来自 VS Code 的一些网络流量经过它。

你会注意到顶部有一些文本输入框。目前你可以忽略其中大部分;最有用的是第一个,Search。这个输入框提供了强大的搜索能力,比如关键词搜索、正则表达式等。例如,如果我们特别关注 VS Code 向其 Extensions Marketplace API 发出的请求,我们可以直接用 marketplace 作为过滤字符串。

这将匹配发往 https://marketplace.visualstudio.com 及其所有子路径的全部请求。

mitmweb — 从 VS Code 捕获的流量示例列表

陈旧的扩展宿主进程

可能即使经过了这一番折腾,你的代理仍然无法捕获扩展的流量。如果 VS Code 的 Extension Host 进程组变得陈旧,就会出现这种情况。要确认这一点,请在终端中运行:

ps -eo pid,ppid,lstart,command | grep -i -E "copilot|extensionHost|Code Helper"

# Should display something like this:

27896 27243 Fri Jul 24 15:40:11 2026.    \
    /Applications/Visual Studio Code.app/Contents/Frameworks/ # (...)

确认所显示的日期。如果它与你重启 VS Code 时的日期和时间不一致,那么扩展宿主进程很可能已经陈旧。解决这个问题很简单:

  • 在 VSCode 中,打开命令面板(Cmd+Shift+P)

  • 运行 “Developer: Restart Extension Host”

  • 之后重新运行你的 ps grep——现在你应该能看到带有今天时间戳的新 PID,对应 Code Helper

Copilot 在你输入任何内容之前做了什么

在 mitmweb 中快速浏览一下来自 VS Code 的网络请求,你会发现其中大多数都与 GitHub 或 Github Copilot 相关。在我们于 VS Code 或 Copilot 扩展中敲下任何一个键之前,就已经发出了一些 HTTP 请求。

高层分析

VS Code 和 Copilot 在引导阶段发出的请求可以归入以下几类:认证与会话、配置与策略、MCP 注册表、仓库与会话上下文、模型发现以及最近仓库。

在接下来的段落中,我们将讨论我对这几类请求分别了解到的情况:请求和响应的 headers 与 payload 中都包含哪些内容。

VS Code 在启动阶段发出的网络请求占比的高层概览

身份验证与会话启动

这是 Copilot 在启动时做的第一件事。它会获取一个 OAuth token,将其换取一个短期 token,并验证用户的权益。整个流程是相当常规的 OAuth 流程;下图对此进行了描述。

描述 Copilot 的 OAuth 身份验证流程的时序图

模型与能力发现

在发出任何 LLM 请求之前,Copilot 会检查你的账户/套餐可以使用哪些模型和智能体能力。

有两类不同的请求。首先,会向 /models 发出一个请求。这个初始请求会返回 Copilot 中可用模型的总体列表。

然后,会向 /agents/swe/models 发出第二个请求。这是一个专门的请求,用于查明与软件工程(SWE)相关的 智能体能力可以使用哪些模型。

Copilot 在其模型发现阶段发出的一系列请求。共两个请求:一个用于获取可用的通用模型,另一个用于获取专门适用于与软件工程相关的智能体能力的模型。

关于提示词、上下文和运行框架的一些细节

引导后(Post bootstrap)才是真正有意思的地方。

Copilot 的模型路由器

在这个实验中的所有 Copilot 测试里,我都选择了 Auto 模式。每发送一条消息后,我都能在任何模型给出回答之前,捕获到一个发往 /models/session/intent 端点的请求。

这里发生的情况是:你的提示词会针对可能的意图进行打分,例如 code-gen、debugging、reasoning 和 tool-use。意图分类的结果会帮助 Copilot 确定由哪个可用模型来完成任务。

时序图,展示了为模型发现和能力发现分别发出的请求

这其实算不上什么秘密;Copilot 的文档中描述了这种行为。不过,能看到其背后的实际请求和响应还是很有趣的。

(机密)环境变量

我开始尝试行内补全和幽灵文本,同时观察通过 HTTP 发送了哪些内容。我已经知道行内补全会把当前文件作为上下文注入到提示词中;它本来就是这么工作的。所以到目前为止并不意外。

但我还是好奇还发送了什么,于是做了个小测试。我往一个.env文件里丢了一个假的密钥——就是那个臭名昭著的、我们所有小孩都被叮嘱不要提交、但有些人还是会提交的文件。

TEST_ENV_VAR_SECRET=”a realistic looking fake token”

编辑这个文件没有触发任何 HTTP 请求,我心想这挺好。然后我打开了一个完全不相关的pyproject.toml,,开始在里面打字。

你瞧,就在我打字的时候,下面这个补全请求被发了出去:

{
    "prompt":"TEST_ENV_VAR_SECRET=\"mysecretenvvar\"\n\nT",
    "suffix":"",
    "max_tokens":500,
    "temperature":0,
    "top_p":1,
    "n":1,
    "stop":["\n\n\n","\n```"],
    "stream":true,
    "extra":{
        "language":"dotenv",
        "next_indent":0,
        "trim_by_indentation":true,
        "prompt_tokens":175,
        "suffix_tokens":0,
        "context":[
           "Path: .env",
           "These are recently edited files. Do not suggest code that has been deleted.\nFile: pyproject.toml\n--- a/file:///Users/rafaelpierre/copilot-mitm/pyproject.toml\n+++ b/file:///Users/rafaelpierre/copilot-mitm/pyproject.toml\n@@ -18,4 +18,4 @@\n     \"polars>=1.41.0\",\n ]\n \n+# testing\n- --- IGNORE ---\nFile: config.ini\n--- a/file:///Users/rafaelpierre/copilot-mitm/config.ini\n+++ b/file:///Users/rafaelpierre/copilot-mitm/config.ini\n@@ -1,2 +1,3 @@\n TEST_CONFIG=\"test-config\"\n \n+# test .env\nEnd of recent edits"
        ]
    },
    "code_annotations":false
}

我的第一个念头是:行吧,我直接对.env文件禁用 Copilot 就好了。结果发现它早就被禁用了;我都忘了这回事。

但这也没用;这个请求是由在pyproject.toml文件中的按键触发的,而那个文件的行内补全可是好好地开着呢。

记一笔:对.env本身或任何其他“secret”扩展关闭行内补全没有任何作用,因为请求并不是由它触发的。但其他请求是可以被触发的。

让 Copilot 帮我回忆一下

我在拦截到的许多补全请求的系统提示词中,都看到了一个 session_store_sql 工具定义。以下是从其中一个此类请求中获取的工具描述:

Query the local session store containing history from past coding sessions.

Uses SQLite syntax (NOT DuckDB or Postgres).

SQL queries are read-only — only SELECT and WITH are allowed.

Use `datetime('now', '-1 day')` for date math (NOT `now() - INTERVAL '1 day'`), FTS5 `MATCH` for text search.

Tables: `sessions`, `turns`, `session_files`, `session_refs`, `checkpoints`, `search_index`.

For column details and query patterns, use the **chronicle** skill.

Actions: 'query' (execute SQL — supports JOINs, FTS5 MATCH, aggregations), 'reindex' (rebuild index from debug logs).

然而,在那之后我没有看到任何工具调用结果被发回。这个工具很可能没有被调用。为了再次确认,我继续尝试通过在聊天中问一个简单的问题来强制触发一次工具调用:“我这周做了什么工作?”。

接下来发生的是模型与一个名为 session-store.db 的本地 SQLite 数据库之间的一番来回交互,而我此前并不知道这个数据库的存在:

一系列 HTTP 请求,包含工具规范、选择、执行,以及最后一个包含工具执行结果的请求,针对的是一个本地 SQLite 数据库,该数据库存储了先前的提示词、操作等。

通过查看这些对话我了解到,session_store_sql 是 Copilot 的 Chronicle 工具 的一部分,该工具让它能够对 session-store.db 运行 SQL 查询。这个数据库存储了会话摘要、你处理过的仓库和分支。

它还存储了你所有的提示词,以及它们对应的 LLM 响应。Copilot 正在为你向它提出的一切保留一份可查询的历史记录,并在需要时深入调取这份历史。

有一点很引人注目:模型事先并不知道数据库的 schema。它最初尝试了下面这个查询,结果失败了。

# Tool definition gets sent

{
    "type":"function_call",
    "name":"session_store_sql",
    "arguments":"{
        \"action\":\"query\",
        \"description\":\"Fetch recent session activity for the past week\",
        \"query\":\"SELECT s.id, s.start_time, s.title, t.turn_index, t.role, t.content FROM sessions s JOIN turns t ON t.session_id = s.id WHERE s.start_time >= datetime('now', '-7 days') ORDER BY s.start_time, t.turn_index;\"
    }",
    "call_id":"call_Ay35CDeV0EFXtvFI8l3VgbWI"
}

# Tool gets executed locally, results are sent back to the agent/LLM:

{
    "type":"function_call_output",
    "call_id":"call_Ay35CDeV0EFXtvFI8l3VgbWI",
    "output":"Error: no such column: s.start_time"
}

随后它内省了 schema 元数据,以查找表定义。之后,它终于能够从我的本地 SQLite 数据库中获取一些记录了。

# Session Store SQLite DB introspection tool call

{
    "type":"function_call",
    "name":"session_store_sql",
    "arguments":"{
        \"action\":\"query\",
        \"description\":\"Inspect session store schema\",
        \"query\":\"
            SELECT name, sql
            FROM sqlite_schema
            WHERE type IN ('table','view');
        \"
    }",
    "call_id":"call_wY9dGEI4DSYbOXzpzPg3JTGN"
}

# Introspection tool call results get sent back to agent/LLM:

{
    "type":"function_call_output",
    "call_id":"call_wY9dGEI4DSYbOXzpzPg3JTGN",
    "output":"Results: 13 rows (source: local)
        | name | sql |
        | --- | --- |
        | schema_version | CREATE TABLE schema_version (\n\t\t\t\tversion INTEGER NOT NULL (...)\
    ",
}

最终,我开始好奇能否查询我的会话数据,并弄清楚那里还存储了哪些内容。于是我从查看元数据入手。

$ sqlite3 ~/Library/Application Support/Code/User/globalStorage/github.copilot-chat/session-store.db

# Output

CREATE TABLE turns (
  id INTEGER PRIMARY KEY AUTOINCREMENT,
  session_id TEXT NOT NULL REFERENCES sessions(id),
  turn_index INTEGER NOT NULL,
  user_message TEXT,
  assistant_response TEXT,
  timestamp TEXT DEFAULT (...),
  UNIQUE(session_id, turn_index)
);

如你所见,user_message 和 assistant_response 以明文形式存储。让我们手动查询其中一些。

$ sqlite3 session-store.db "SELECT substr(user_message,1,60) FROM turns LIMIT 5;"
What is ML?
hello
testing

这些是我之前发给 Copilot 的一些消息,用来测试我的 mitmproxy 抓包,所以再一次,没什么意外。但那些可能包含一些更……有问题的内容的消息呢?

为了弄清楚这一点,我给 Copilot 发送了一条包含虚假机密数据的聊天消息:一个假的 GitHub token、一个假的 AWS 密钥、一个内含密码的连接字符串。

然后,我回到数据库查看写入了什么。

$ sqlite3 session-store.db "SELECT user_message FROM turns \
    WHERE user_message LIKE '%ghp_%' OR user_message LIKE '%postgres://%';"

...
GITHUB_TOKEN=ghp_«fake token, stored exactly as typed»
DATABASE_URL=postgres://admin:«password»@db.example.com:5432/prod
...

我承认,我一度很想把“全都在那儿,明文存储”当作标题。

但尽管这是事实,我的结论其实没那么令人担忧——而且我认为,实际上更有趣:AI 编程工具正在成为有状态系统。

AI 编程工具正在变成有状态系统。它们越来越多地把用户工作区 + 近期编辑 + 对话 + 工具 + 历史记录 + 模型路由组合在一起。

每新增一种上下文来源,都会提升实用性,并增加系统可访问的开发者状态量。但它也带来了额外的挑战:上下文膨胀加剧、数据机密性和隐私方面的担忧。

虽然我很享受做这次逆向工程练习,但我也开始好奇,我的假设是否有实际代码作为依据。为了确认这些假设,我需要去看代码。

将这些发现与源代码进行对照

未加密的会话存储

会话存储代码是 Chronicle 扩展的一部分,位于sessionStore.ts中。表定义与我在磁盘上看到的完全一致:user_message和assistant_response以纯文本形式存储,没有任何列级掩码之类的处理。

但schema本身并不能说明是否有东西在写入时对数据进行了清洗。写入路径才能说明。下面是记录每一轮对话的插入语句:

INSERT INTO turns (session_id, turn_index, user_message, assistant_response, timestamp)
VALUES (?, ?, ?, ?, ?)

……以及绑定到它的值:

turn.session_id,
turn.turn_index,
turn.user_message ?? null,
turn.assistant_response ?? null,
turn.timestamp ?? new Date().toISOString(),

turn.user_message 原样写入。我在代码中搜索了写入路径上任何脱敏、净化、密钥过滤或掩码处理。什么都没有,不存在任何清洗步骤。明文存储不是 bug,也不是遗漏的边界情况;它就是这个代码本来的行为。

这就回答了第一个问题:它是刻意的,从这个意义上说,从来就没有构建任何东西来阻止它。

泄还是不泄

我在 mitm 抓包中看到的 “最近编辑的文件” 字符串来自 recentEdits.tsx。默认的滑动窗口行为是硬编码的:最多 20 个文件、8 条编辑摘要,以及每处改动周围 3 行上下文,这就是为什么一行我并未改动过的内容(那行含有伪造密钥)会成为发往 Copilot API 的 HTTP 请求的一部分。

任何地方都没有默认的 .env 规则。在个人版计划上,没有任何东西把 .env 视为特殊文件,也没有与当前空间的 .gitignore 做任何集成。

有一个排除门控,但它绑定的是“仓库策略”,这是 Business/Enterprise 的 GitHub 功能,且由管理员控制。

临别之言

能力越大,责任越大

事实证明,这是一次极好的练习,让我理解了 AI 编程工具是如何实现其运行框架的。我相信,其中许多细节和实践可以被构建自己 AI 系统的不同团队所借鉴吸收。

在构建这类系统时,我经常问自己的一些问题,在经历了这一切之后依然存在。应该注入什么上下文?应该向模型发送什么?什么应该留在本地?模型应该能够调用哪些工具?什么会被存入短期记忆?什么会被提升为长期记忆?

上下文正在成为产品本身

我越来越认为,上下文正在成为产品本身。

模型和 SOTA 基准测试结果当然很重要。但 AI 编程工具——以及推而广之,所有 AI 工具——之间真正的差异化,似乎正在转向它们能否很好地组装正确的上下文:你的代码、最近的变更、操作、对话、工具、历史记录,以及任何其他可能有助于解决当前任务的信息。这带来了两个挑战。

第一个是工程问题:更多的上下文并不一定意味着更好的上下文。挑战在于保持上下文的精简、相关且对缓存友好,同时不让模型淹没在提示词膨胀之中。

第二点关乎隐私与保密。harness 收集并持久化的上下文数据越多,就越需要审慎地界定哪些内容可以跨越边界——在文件、会话、机器之间,最终到模型 API 之间。

Copilot 显然正朝着这个方向演进,我发现的其中一些做法很巧妙。也有一些让我感到别扭。而就目前而言,这些都没有说服我再次成为付费用户。

但它确实让我确信了另一件事。如果你在构建 AI 应用,逆向工程并研究模型周围的 harness,可能比研究模型本身能让你学到更多。

希望你喜欢这篇文章。如果你有任何疑问,如果它引起了你的共鸣,如果你有建议,请回复这封邮件或留下评论——每一封我都会读。

来源:Hacker News 热门(buzzing.cc 中文翻译) · lighthousenewsletter.com